diff --git a/.gitignore b/.gitignore index 908eae39d..4d121433c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ run src/main/resources/assets/customnpcs/api df/ tmp/ +src/test + diff --git a/1.11_Beta_Update.md b/1.11_Beta_Update.md new file mode 100644 index 000000000..815c62e5d --- /dev/null +++ b/1.11_Beta_Update.md @@ -0,0 +1,92 @@ +# CNPC+ 1.11 Beta - The Scripting Update + +🟡 **In Testing** + + + +## Features: ⚒️ + +- **Syntax Highlighting:** + +> Full color-coded script editor. Keywords, types, variables, methods, strings, and comments are all distinct. Scope-aware coloring distinguishes local fields from globals and chained method calls. + + + +- **Autocomplete:** + +> Smart suggestions as you type. Context-aware for static vs instance access, shows method parameters, resolves chained calls, and suggests auto imports for classes. Frequently used members prioritized. + + + +- **Error Detection:** + +> Catch mistakes before running. Type mismatches, wrong arguments, missing methods, unused imports, duplicate definitions, and more -- all underlined with hover messages explaining the issue. + + + +- **Hover Information:** + +> Hover any symbol to see its type, documentation, and declaration. Works on methods, fields, classes, and even generics. Click to pin tooltips in place. + + + +- **Script Editor Overhaul:** + +> - **Line Numbers** - Gutter with current line highlighting. +> - **Go To Line** - CTRL+G to jump to any line. +> - **Search & Replace** - CTRL+F / CTRL+H with real-time highlighting. +> - **Go To Definition** - CTRL+Click to jump to any symbol. +> - **Smart Undo/Redo** - Word-level instead of character-level. +> - **Line Operations** - Duplicate, delete, move up/down. +> - **Comment Toggle** - CTRL+/ for selected lines. +> - **Rename** - Rename a symbol across all occurrences. +> - **Smooth Scrolling** - Eased scroll animation. +> - **Indent Guides** - Scope-highlighted vertical indent lines. +> - **Brace Matching** - Highlighted pairs, red for unclosed. +> - **Smart Brackets** - Auto-pairing for `{}`, `()`, `[]`, `""`, `''`. +> - **Fullscreen Mode** - Expand editor to fill the game window. +> - **Shortcut Overlay** - View all keyboard shortcuts at a glance. + + + +- **Java Scripting (Janino):** + +> Write scripts in real Java compiled at runtime. Name methods after hooks for automatic wiring. Language selector per tab to switch between Java and JavaScript. External `.java` file support. + + + +- **Client-Side Scripting:** + +> Run scripts on the client for responsive experiences. Server-controlled -- off by default, must be explicitly enabled. Script files sync automatically on login and reload. + + + +- **Cloner Tab Overhaul:** + +> Folder-based organization for saved NPCs, items, and entries. Full-width directory browser for large collections. Improved tab navigation. + + + +- **Addon API Support:** + +> Addons can ship type definitions that load into the script editor automatically. Full autocomplete, hover info, and documentation for addon APIs. Addons can extend existing CNPC+ types with new methods. + + + +- **Animation Improvements:** + +> Data store for passing data across animation events and frames. New consumer-based task system for animation lifecycle. Expanded scripting API for animation control. + + + +**Extras** + +- Player Dialog Events now work like NPC Dialog Events. +- Attack speed configuration for linked items. +- Client-side balance prediction during Trader interactions. +- Right-click cycling on multi-option NPC buttons (Hair, Eyes, Fur, etc.). +- Fixed quest cooldown for MC Custom and RL Custom timer types. +- Fixed Bard music restarting when opening dialogs. +- Fixed NPC biome spawn settings not saving after editing. +- Fixed block waypoint issues. +- Fixed script config syncing between server and client. diff --git a/1.11_Update.md b/1.11_Update.md new file mode 100644 index 000000000..c6101a559 --- /dev/null +++ b/1.11_Update.md @@ -0,0 +1,118 @@ +# ✏️ CustomNPC+ 1.11 - The Scripting Update ✏️ + +--- + +The **Scripting Update** completely transforms how you write scripts in CNPC+! A brand new **Script Editor** with **syntax highlighting**, **autocomplete**, and **error detection** makes scripting feel like a real IDE. Write scripts in **Java** with the new Janino engine, enable **client-side scripting** for responsive experiences, and organize your Cloner with **folders**! + +--- + +## Script Editor 📝 + +The script editor has been rebuilt from the ground up into a **full-featured code editor** right inside Minecraft. + +### Writing Code + +- **Syntax Highlighting** - Keywords, types, variables, methods, strings, and comments are all color-coded automatically. The editor understands your code -- local variables, global fields, and chained method calls each get their own color +- **Autocomplete** - Start typing and get smart suggestions for methods, fields, and classes. Suggestions know whether you're in a static or instance context, and frequently used members appear first +- **Auto Imports** - Type a class name and the editor suggests the correct import for you +- **Error Detection** - Catch mistakes before you even run your script! Wrong argument types, missing methods, type mismatches, unused imports, and more are all underlined with detailed error messages on hover + +> Hover over any symbol to see its **full type information**, documentation, and declaration -- just like a desktop IDE. + +--- + +### Navigation + +- **Go To Line** - `CTRL+G` jumps to any line instantly +- **Search & Replace** - `CTRL+F` to search, `CTRL+H` to replace. All matches highlight in real-time +- **Go To Definition** - `CTRL+Click` any method, field, or type to jump straight to its definition +- **Fullscreen Mode** - Expand the editor to fill your entire game window for maximum workspace + +### Editing + +- **Undo/Redo** - Smart undo that works on whole words at a time, not individual characters +- **Line Operations** - Duplicate lines with `CTRL+D`, delete lines, and move lines up or down with shortcuts +- **Comment Toggle** - `CTRL+/` to comment or uncomment selected lines +- **Rename** - Rename a variable or method and every occurrence updates at once +- **Copy Whole Line** - Copy with nothing selected grabs the entire line +- **Triple Click** - Select an entire line instantly + +### Visual Polish + +- **Line Numbers** - A clean gutter with line numbers and current line highlighting +- **Smooth Scrolling** - Eased scroll animations for a polished feel +- **Indent Guides** - Vertical lines show your code's nesting depth, with the current scope highlighted in green +- **Brace Matching** - Matching `{}` braces light up, and unclosed braces turn red +- **Smart Brackets** - Typing `{` automatically creates the closing `}` with correct indentation. Same for `()`, `[]`, `""`, and `''` +- **Keyboard Shortcut Overlay** - Press a button to see all available shortcuts at a glance + +--- + +## Java Scripting ☕ + +Write scripts in **real Java** instead of JavaScript! Powered by the **Janino** compiler, your Java code is compiled and runs natively. + +- **Full Java Language** - Use classes, interfaces, enums, generics, lambdas, and everything you know from Java +- **Automatic Hook Resolution** - Just name your methods after the hook you want (e.g., `init`, `tick`, `interact`) and they're wired up automatically +- **Language Selector** - Each script tab has a dropdown to choose between JavaScript and Java. Mix and match across tabs +- **External Files** - Write `.java` scripts in an external editor and they're loaded automatically + +> Java and JavaScript scripts coexist side by side. Pick whichever language fits your workflow! + +--- + +## Client-Side Scripting 🖥️ + +Scripts can now run **on the client** for responsive, visual experiences! + +- **Server Controlled** - Server owners decide whether client scripting is allowed. Players can't enable it on their own +- **Automatic Sync** - Script files are sent from the server to all connected clients on login, and re-synced whenever scripts are reloaded +- **Safe by Default** - Client scripting is off by default and must be explicitly enabled in the server config + +> Client-side scripts open the door to custom UI effects, visual feedback, and client-only hooks -- all while the server stays in full control. + +--- + +## Cloner Tab Overhaul 📂 + +The Cloner now supports **folders** for organizing your saved NPCs, items, and entries! + +- **Folder System** - Create folders and subfolders to keep your cloner organized however you like +- **Full Screen Browser** - A new full-width directory view makes browsing large collections much easier +- **Improved Tabs** - Better tab navigation for switching between cloner categories + +--- + +## Addon API Support 📘 + +Addon developers can now ship **type definitions** alongside their mods, giving scripters full autocomplete and documentation for addon APIs! + +- **Automatic Loading** - Any mod can include API definitions and they'll appear in the script editor automatically +- **Patch Support** - Addons can extend existing CNPC+ types with new methods (e.g., a DBC addon adding `getDBCPlayer()` to the Player type) +- **Full Documentation** - Parameter names, return types, and descriptions all show up in autocomplete and hover info + +> If you're a scripter using addons, you get autocomplete for their APIs with zero setup. If you're an addon developer, just include your definitions and everything works. + +--- + +## Animation Improvements 🎬 + +- **Data Store** - Animations can now store and pass data across events and frames for more complex animation logic +- **Task System** - A new consumer-based system for reacting to animation lifecycle events, replacing the old approach +- **Expanded API** - More animation control exposed to the scripting API + +--- + +## Additional Changes 🔧 + +- **Player Dialog Events** - Now work the same way as NPC Dialog Events for consistency +- **Linked Item Attack Speed** - Added attack speed configuration to linked items +- **Trader Balance Preview** - Client-side balance prediction shows your expected balance during trades +- **Right-Click Cycling** - Multi-option NPC buttons (Hair, Eyes, Fur, etc.) can now be right-clicked to cycle backwards +- **Quest Cooldown Fix** - Fixed cooldown not working for MC Custom and RL Custom timer types +- **Bard Music Fix** - Fixed music restarting or duplicating when opening dialogs +- **Biome Spawn Fix** - Fixed NPC biome spawn settings not saving after editing +- **Block Waypoint Fix** - Fixed issues with block waypoints +- **Script Config Fix** - Fixed script configuration not syncing properly between server and client + +--- diff --git a/build.gradle b/build.gradle index 2a9f8b1da..7cc75bcbf 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'com.gtnewhorizons.gtnhconvention' id 'idea' + id "dts.typescript-generator" } version = "1.11-beta6.1" @@ -56,5 +57,18 @@ sourceSets.main.java { srcDirs += apiDir } -// Modify the existing 'build' task to depend on 'updateAPI' -//tasks.apiClasses.dependsOn 'updateAPI' +// ============================================================================ +// TypeScript Definition Generation Task +// Generates .d.ts files from Java API sources for scripting IDE support +// ============================================================================ +// TypeScript plugin is applied above in the main plugins block + +tasks.named("generateTypeScriptDefinitions").configure { + sourceDirectories = ['src/api/java'] + outputDirectory = "src/main/resources/assets/${project.property("modId")}/api" + apiPackages = ['noppes.npcs.api', 'net.minecraft'] as Set + cleanOutputFirst = true // Clean old generated files before regenerating +} +processResources.dependsOn generateTypeScriptDefinitions +sourcesJar.dependsOn generateTypeScriptDefinitions + diff --git a/dependencies.gradle b/dependencies.gradle index 486ef7430..2ed8d72da 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -45,6 +45,8 @@ dependencies { shadowImplementation(project.files("tools/standalone-JaninoLoader-1.0.1-ALPHA.jar")) annotationProcessor(project.files("tools/standalone-JaninoLoader-1.0.1-ALPHA.jar")) + + testImplementation("junit:junit:4.13.2") if(embedMixin){ // Normal Jar will include built in Mixins diff --git a/settings.gradle b/settings.gradle index ec9681d97..e58786789 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,14 @@ - pluginManagement { +// resolutionStrategy { +// eachPlugin { +// if(requested.id.toString() == "dts.typescript-generator") { +// useModule("com.github.bigguy345:dts-gradle-plugin:95b8ab4") +// } +// } +// } +// repositories { + maven { url "https://jitpack.io"} maven { // RetroFuturaGradle name "GTNH Maven" @@ -19,3 +27,6 @@ pluginManagement { plugins { id 'com.gtnewhorizons.gtnhsettingsconvention' version '1.0.27' } + +// Include the local gradle-plugins composite build so plugin is resolved locally +includeBuild 'gradle-plugins' \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/ClientProxy.java b/src/main/java/noppes/npcs/client/ClientProxy.java index a221e35ae..9e91d5556 100644 --- a/src/main/java/noppes/npcs/client/ClientProxy.java +++ b/src/main/java/noppes/npcs/client/ClientProxy.java @@ -152,6 +152,8 @@ import noppes.npcs.client.gui.script.GuiScriptGlobal; import noppes.npcs.client.gui.script.GuiScriptInterface; import noppes.npcs.client.gui.util.script.PackageFinder; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.type.ClassIndex; import noppes.npcs.client.model.ModelNPCGolem; import noppes.npcs.client.model.ModelNpcCrystal; import noppes.npcs.client.model.ModelNpcDragon; @@ -835,6 +837,8 @@ public boolean isGUIOpen() { public void buildPackageIndex() { try { PackageFinder.init(Thread.currentThread().getContextClassLoader()); + ClassIndex.init(); + JSTypeRegistry.getInstance().initializeFromResources(); } catch (IOException e) { } } diff --git a/src/main/java/noppes/npcs/client/gui/GuiScript.java b/src/main/java/noppes/npcs/client/gui/GuiScript.java index 8d960e142..796064b60 100644 --- a/src/main/java/noppes/npcs/client/gui/GuiScript.java +++ b/src/main/java/noppes/npcs/client/gui/GuiScript.java @@ -16,6 +16,8 @@ import noppes.npcs.client.gui.util.GuiNpcLabel; import noppes.npcs.client.gui.util.GuiNpcTextArea; import noppes.npcs.client.gui.util.GuiScriptTextArea; +import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer; +import noppes.npcs.constants.EnumScriptType; import noppes.npcs.constants.ScriptContext; import noppes.npcs.controllers.ScriptContainer; import noppes.npcs.controllers.ScriptController; @@ -88,18 +90,25 @@ public void initGui() { super.initGui(); this.guiTop += 10; - // ==================== TOP BUTTONS (hidden in fullscreen) ==================== - if (!isFullscreen) { - GuiMenuTopButton top; - addTopButton(top = new GuiMenuTopButton(13, guiLeft + 4, guiTop - 17, "script.scripts")); - addTopButton(new GuiMenuTopButton(16, guiLeft + (xSize - 102), guiTop - 17, "eventscript.eventScripts")); - addTopButton(new GuiMenuTopButton(17, guiLeft + (xSize - 22), guiTop - 17, "X")); - top.active = showScript; - addTopButton(top = new GuiMenuTopButton(14, top, "gui.settings")); - top.active = !showScript; - addTopButton(new GuiMenuTopButton(15, top, "gui.website")); + if (isFullscreen) { + FullscreenConfig.paddingTop = 30; } + // ==================== TOP BUTTONS ==================== + boolean isFullscreenView = isFullscreen && showScript; + int menuX = isFullscreenView ? FullscreenConfig.paddingLeft : guiLeft + 4; + int menuY = isFullscreenView ? FullscreenConfig.paddingTop - 20 : guiTop - 17; + int rightX = isFullscreenView ? width - FullscreenConfig.paddingRight : guiLeft + xSize; + + GuiMenuTopButton top; + addTopButton(top = new GuiMenuTopButton(13, menuX, menuY, "script.scripts")); + addTopButton(new GuiMenuTopButton(16, rightX - 102, menuY, "eventscript.eventScripts")); + addTopButton(new GuiMenuTopButton(17, rightX - 22, menuY, "X")); + top.active = showScript; + addTopButton(top = new GuiMenuTopButton(14, top, "gui.settings")); + top.active = !showScript; + addTopButton(new GuiMenuTopButton(15, top, "gui.website")); + if (showScript) { initScriptView(); } else { @@ -160,6 +169,10 @@ private void initScriptView() { activeArea.setLanguage(script.getLanguage()); activeArea.setScriptContext(getScriptContext()); + + // Set editor globals based on the active NPC hook + String hookName = EnumScriptType.values()[activeTab].function; + applyEditorGlobals(activeArea, hookName); // Setup fullscreen key binding GuiScriptTextArea.KEYS.FULLSCREEN.setTask(e -> { @@ -225,6 +238,19 @@ private void initSettingsView() { addButton(new GuiNpcButton(106, guiLeft + 232, guiTop + 71, 150, 20, "script.openfolder")); } + // Apply editor globals for the active NPC hook. + private void applyEditorGlobals(GuiScriptTextArea activeArea, String hookName) { + if (activeArea == null) + return; + + ScriptTextContainer textContainer = activeArea.getContainer(); + if (textContainer == null) + return; + + if (script != null) + textContainer.setEditorGlobalsMap(script.getEditorGlobals(hookName)); + } + @Override protected String getConsoleText() { Map map = this.script.getOldConsoleText(); @@ -351,24 +377,4 @@ public void customScrollClicked(int i, int j, int k, GuiCustomScroll scroll) { initGui(); } } - - @Override - public void drawScreen(int mouseX, int mouseY, float partialTicks) { - super.drawScreen(mouseX, mouseY, partialTicks); - - // Draw fullscreen button when in script view (GuiScript uses 0-based activeTab) - if (showScript) { - fullscreenButton.draw(mouseX, mouseY); - } - } - - @Override - public void mouseClicked(int mouseX, int mouseY, int mouseButton) { - // Check fullscreen button first when in script view - if (showScript && fullscreenButton.mouseClicked(mouseX, mouseY, mouseButton)) { - return; - } - super.mouseClicked(mouseX, mouseY, mouseButton); - } } - diff --git a/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java b/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java index 82012429f..f461c66f6 100644 --- a/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java +++ b/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java @@ -32,15 +32,11 @@ import noppes.npcs.controllers.data.IScriptHandler; import noppes.npcs.controllers.data.IScriptHandlerPacket; import noppes.npcs.controllers.data.IScriptUnit; +import noppes.npcs.janino.JaninoScript; import noppes.npcs.scripted.item.ScriptCustomItem; import org.lwjgl.opengl.Display; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; public class GuiScriptInterface extends GuiNPCInterface implements GuiYesNoCallback, IGuiData, ITextChangeListener, ICustomScrollListener, IJTextAreaListener, ITextfieldListener { @@ -277,6 +273,24 @@ private void initScriptEditorTab(int yoffset) { // Set the script context for context-aware hook autocomplete activeArea.setScriptContext(getScriptContext()); + activeArea.enableCodeHighlighting(); + + // For JaninoScripts, add implicit imports (default imports + hook signature types) + // These allow the syntax highlighter to resolve types without explicit import statements + if (container instanceof JaninoScript) { + JaninoScript janinoScript = (JaninoScript) container; + + // Add default imports (e.g., noppes.npcs.api.*, noppes.npcs.api.entity.*, etc.) + activeArea.addImplicitImports(janinoScript.getDefaultImports()); + + // Add hook types from signatures (parameters + return types) + // e.g., INpcEvent.InitEvent, Color, String, IOverlayContext, etc. + Set hookTypes = janinoScript.getHookTypes(); + activeArea.addImplicitImports(hookTypes.toArray(new String[0])); + + // Format to update which implicit imports are actually used + activeArea.formatCodeText(); + } // Setup fullscreen key binding GuiScriptTextArea.KEYS.FULLSCREEN.setTask(e -> { @@ -366,27 +380,17 @@ public GuiScriptInterface setDimensions(int x, int y) { protected ScriptContext getScriptContext() { return handler.getContext(); } - - // ==================== RENDERING ==================== - - @Override - public void drawScreen(int mouseX, int mouseY, float partialTicks) { - super.drawScreen(mouseX, mouseY, partialTicks); - - // Draw fullscreen button on top of everything when on script editor tab - // Skip for useSettingsToggle GUIs (like GuiScript) - they handle this themselves - if (this.activeTab > 0 && !useSettingsToggle && !handler.isSingleContainer()) { - fullscreenButton.draw(mouseX, mouseY); - } - } - + // ==================== MOUSE HANDLING ==================== @Override public void mouseClicked(int mouseX, int mouseY, int mouseButton) { - // Check fullscreen button first when on script editor tab - // Skip for useSettingsToggle GUIs (like GuiScript) - they handle this themselves - if (this.activeTab > 0 && !useSettingsToggle && !handler.isSingleContainer() && fullscreenButton.mouseClicked(mouseX, mouseY, mouseButton)) { + // Check if click is within autocomplete menu bounds and consume it if so + GuiScriptTextArea activeArea = getActiveScriptArea(); + boolean isOverAutocomplete = activeArea != null + && activeArea.isPointOnAutocompleteMenu(mouseX, mouseY); + if (isOverAutocomplete) { + activeArea.mouseClicked(mouseX, mouseY, mouseButton); return; } diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java b/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java index f8cddbf72..658cfc022 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java @@ -161,9 +161,8 @@ public void mouseClicked(int i, int j, int k) { if (subgui != null) subgui.mouseClicked(i, j, k); else { - for (GuiNpcTextField tf : new ArrayList(textfields.values())) - if (tf.enabled) - tf.mouseClicked(i, j, k); + mouseEvent(i, j, k); + vanillaMouseClicked(i, j, k); for (GuiScrollWindow guiScrollableComponent : scrollWindows.values()) { guiScrollableComponent.mouseClicked(i, j, k); @@ -181,8 +180,11 @@ public void mouseClicked(int i, int j, int k) { return; } } - mouseEvent(i, j, k); - vanillaMouseClicked(i, j, k); + + for (GuiNpcTextField tf : new ArrayList(textfields.values())) + if (tf.enabled) + tf.mouseClicked(i, j, k); + } } @@ -386,9 +388,6 @@ public void drawScreen(int i, int j, float f) { drawCenteredString(fontRendererObj, title, width / 2, guiTop + 4, 0xffffff); for (GuiNpcLabel label : labels.values()) label.drawLabel(this, fontRendererObj); - for (GuiNpcTextField tf : textfields.values()) { - tf.drawTextBox(i, j); - } for (GuiCustomScroll scroll : scrolls.values()) { scroll.updateSubGUI(subGui); scroll.drawScreen(i, j, f, !subGui && scroll.isMouseOver(i, j) ? Mouse.getDWheel() : 0); @@ -411,10 +410,10 @@ public void drawScreen(int i, int j, float f) { button.drawHover(i, j, subGui); } } - for (GuiNpcTextField textField : textfields.values()) { - if (textField.hasHoverText()) { - textField.drawHover(i, j, subGui); - } + for (GuiNpcTextField tf : textfields.values()) { + tf.drawTextBox(i, j); + if (tf.hasHoverText()) + tf.drawHover(i, j, subGui); } for (GuiScreen gui : extra.values()) diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java index be148c11d..1ed86e4d4 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -8,17 +8,23 @@ import noppes.npcs.client.ClientProxy; import noppes.npcs.client.gui.script.GuiScriptInterface; import noppes.npcs.client.gui.util.key.OverlayKeyPresetViewer; -import noppes.npcs.client.gui.util.script.BracketMatcher; -import noppes.npcs.client.gui.util.script.CommentHandler; -import noppes.npcs.client.gui.util.script.CursorNavigation; -import noppes.npcs.client.gui.util.script.GoToLineDialog; -import noppes.npcs.client.gui.util.script.IndentHelper; -import noppes.npcs.client.gui.util.script.JavaTextContainer; +import noppes.npcs.client.gui.util.script.*; import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; -import noppes.npcs.client.gui.util.script.RenameRefactorHandler; -import noppes.npcs.client.gui.util.script.ScrollState; -import noppes.npcs.client.gui.util.script.SearchReplaceBar; -import noppes.npcs.client.gui.util.script.SelectionState; +// New interpreter system imports +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteMenu; +import noppes.npcs.client.gui.util.script.interpreter.ScriptLine; +import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.Token; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.hover.GutterIconRenderer; +import noppes.npcs.client.gui.util.script.interpreter.hover.HoverState; +import noppes.npcs.client.gui.util.script.interpreter.hover.TokenHoverRenderer; +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteManager; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; import noppes.npcs.client.key.impl.ScriptEditorKeys; import noppes.npcs.constants.ScriptContext; import noppes.npcs.util.ValueUtil; @@ -36,7 +42,7 @@ /** * Script text editor component with syntax highlighting, bracket matching, * smooth scrolling, and IDE-like features. - *

+ * * Helper classes used: * - ScrollState: smooth scroll animation and state management * - SelectionState: cursor position and text selection management @@ -47,10 +53,12 @@ */ public class GuiScriptTextArea extends GuiNpcTextField { + private GuiScriptInterface parent; + // ==================== DIMENSIONS & POSITION ==================== public int x; public int y; - + // ==================== STATE FLAGS ==================== public boolean active = false; public boolean enabled = true; @@ -60,11 +68,11 @@ public class GuiScriptTextArea extends GuiNpcTextField { public boolean tripleClicked = false; private int clickCount = 0; private long lastClicked = 0L; - + // ==================== TEXT & CONTAINER ==================== public String text = null; public String highlightedWord; - private JavaTextContainer container = null; + private ScriptTextContainer container = null; private boolean enableCodeHighlighting = false; // Extra empty lines to allow padding at the bottom of the editor viewport private int bottomPaddingLines = 6; @@ -89,16 +97,31 @@ private int getPaddedLineCount() { // ==================== HELPER CLASS INSTANCES ==================== private final ScrollState scroll = new ScrollState(); private final SelectionState selection = new SelectionState(); - + private final HoverState hoverState = new HoverState(); + /** When true, clicking a token will pin its hover tooltip until dismissed. */ + public boolean clickToPinEnabled = false; + // ==================== UI COMPONENTS ==================== private int cursorCounter; private ITextChangeListener listener; private static int LINE_NUMBER_GUTTER_WIDTH = 25; - + + // ==================== GUTTER ICONS ==================== + /** Hover state for gutter icons - tracks which icon the mouse is over */ + private MethodInfo hoveredGutterMethod = null; + // ==================== UNDO/REDO ==================== public List undoList = new ArrayList<>(); public List redoList = new ArrayList<>(); public boolean undoing = false; + + // Atomic undo: group typing into word-based undo steps + private long lastTypingTime = 0; + private int lastTypingPos = -1; + + // Clipboard tracking for line-copy paste behavior + private boolean lastCopyWasLine = false; + private String lastCopiedLineText = null; // ==================== KEYS ==================== public static final ScriptEditorKeys KEYS = new ScriptEditorKeys(); @@ -106,18 +129,24 @@ private int getPaddedLineCount() { // ==================== SEARCH/REPLACE ==================== public static final SearchReplaceBar searchBar = new SearchReplaceBar(); - + // ==================== GO TO LINE ==================== private final GoToLineDialog goToLineDialog = new GoToLineDialog(); // ==================== RENAME REFACTOR ==================== private final RenameRefactorHandler renameHandler = new RenameRefactorHandler(); + // ==================== AUTOCOMPLETE ==================== + private final AutocompleteManager autocompleteManager = new AutocompleteManager(); + // ==================== CONSTRUCTOR ==================== public GuiScriptTextArea(GuiScreen guiScreen, int id, int x, int y, int width, int height, String text) { super(id, guiScreen, x, y, width, height, null); init(x, y, width, height, text); + + if (guiScreen instanceof GuiScriptInterface) + this.parent = (GuiScriptInterface) guiScreen; } public void init(int x, int y, int width, int height, String text) { @@ -134,12 +163,13 @@ public void init(int x, int y, int width, int height, String text) { this.searchBaseHeight = 0; this.searchAppliedOffset = 0; this.searchBaseInitialized = false; - + KEYS_OVERLAY.openOnClick = true; initGui(); initializeKeyBindings(); + // Propagate click-to-pin option into hover state + hoverState.setClickToPinEnabled(clickToPinEnabled); } - public void initGui() { int endX = x + width, endY = y + height; int xOffset = hasVerticalScrollbar() ? -8 : -2; @@ -147,11 +177,16 @@ public void initGui() { KEYS_OVERLAY.borderCol1 = KEYS_OVERLAY.borderCol2 = 0xFF3c3c3c; int overlayWidth = 160; KEYS_OVERLAY.initGui(x + (width - overlayWidth) / 2 + 5, y + height / 10, overlayWidth, - height - height / 5 - 10); + height - height / 5 - 10); KEYS_OVERLAY.viewButton.scale = 0.45f; KEYS_OVERLAY.viewButton.initGui(endX + xOffset, endY - 26); - + + // Dismiss autocomplete on resize to avoid positioning issues + if (autocompleteManager != null) { + autocompleteManager.dismiss(); + } + // Initialize search bar (preserves state across initGui calls) searchBar.initGui(x, y, width); if (searchBar.isVisible()) { // If open @@ -160,7 +195,7 @@ public void initGui() { if (!active) // Focus search if opening another script tab & bar is open searchBar.focus(false); } - + // Initialize Go To Line dialog goToLineDialog.initGui(x, y, width); } @@ -203,7 +238,7 @@ public void scrollToPosition(int position) { int visibleLines = effectiveHeight / container.lineHeight; // Calculate how many lines the search bar covers int linesHiddenBySRB = searchBarOffset > 0 ? (int) Math.ceil( - (double) searchBarOffset / container.lineHeight) : 0; + (double) searchBarOffset / container.lineHeight) : 0; for (int i = 0; i < container.lines.size(); i++) { LineData ld = container.lines.get(i); @@ -426,7 +461,7 @@ public void scrollToPosition(int pos) { } @Override - public JavaTextContainer getContainer() { + public ScriptTextContainer getContainer() { return container; } @@ -453,48 +488,124 @@ public int getViewportWidth() { return width - LINE_NUMBER_GUTTER_WIDTH - 8; // Account for gutter and scrollbar } }); + + // Initialize Autocomplete Manager with callback + autocompleteManager.setInsertCallback(new AutocompleteManager.InsertCallback() { + @Override + public void insertText(String text, int startPosition) { + // Replace text from startPosition to current cursor + String fullText = GuiScriptTextArea.this.text; + int cursorPos = selection.getCursorPosition(); + + // Bounds check to prevent StringIndexOutOfBoundsException + int start = Math.max(0, Math.min(startPosition, fullText.length())); + int cursor = Math.max(start, Math.min(cursorPos, fullText.length())); + + String before = fullText.substring(0, start); + String after = fullText.substring(cursor); + setText(before + text + after); + selection.reset(start + text.length()); + scrollToCursor(); + } + + @Override + public void replaceTextRange(String text, int startPosition, int endPosition) { + // Replace text from startPosition to endPosition + String fullText = GuiScriptTextArea.this.text; + + // Bounds check to prevent StringIndexOutOfBoundsException + int start = Math.max(0, Math.min(startPosition, fullText.length())); + int end = Math.max(start, Math.min(endPosition, fullText.length())); + + String before = fullText.substring(0, start); + String after = fullText.substring(end); + setText(before + text + after); + selection.reset(start + text.length()); + scrollToCursor(); + } + + @Override + public void addImport(String importPath) { + // Add import statement and sort all imports + addAndSortImport(importPath); + } + + @Override + public int getCursorPosition() { + return selection.getCursorPosition(); + } + + @Override + public void setCursorPosition(int position) { + selection.reset(Math.max(0, Math.min(position, GuiScriptTextArea.this.text.length()))); + scrollToCursor(); + } + + @Override + public String getText() { + return GuiScriptTextArea.this.text; + } + + @Override + public int[] getCursorScreenPosition() { + // Calculate screen position of cursor for menu placement + int cursorLine = getCursorLineIndex(); + int cursorCol = 0; + if (container != null && container.lines != null && cursorLine < container.lines.size()) { + LineData ld = container.lines.get(cursorLine); + String lineText = ld.text; + int cursorOffset = selection.getCursorPosition() - ld.start; + cursorCol = ClientProxy.Font.width(lineText.substring(0, Math.min(cursorOffset, lineText.length()))); + } + + int screenX = GuiScriptTextArea.this.x + LINE_NUMBER_GUTTER_WIDTH + 1 + cursorCol; + int lineY = cursorLine - scroll.getScrolledLine(); + int screenY = GuiScriptTextArea.this.y + lineY * (container != null ? container.lineHeight : 12); + + return new int[] { screenX, screenY }; + } + + @Override + public int[] getViewportDimensions() { + Minecraft mc = Minecraft.getMinecraft(); + ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + return new int[] { sr.getScaledWidth(), sr.getScaledHeight() }; + } + }); } public boolean fullscreen() { return GuiScriptInterface.isFullscreen; } - - public void setLanguage(String language) { - if (this.container != null) { - // this.container.setLanguage(language); - if (this.enableCodeHighlighting) { - this.container.formatCodeText(); - } - } - } - + // ==================== RENDERING ==================== public void drawTextBox(int xMouse, int yMouse) { if (!visible) return; clampSelectionBounds(); - - // Dynamically calculate gutter width based on line count digits + + // Dynamically calculate gutter width based on line count digits + icon space if (container != null && container.linesCount > 0) { int maxLineNum = container.linesCount; String maxLineStr = "" + maxLineNum; int digitWidth = ClientProxy.Font.width(maxLineStr); - LINE_NUMBER_GUTTER_WIDTH = digitWidth + 10; // 10px total padding (5px left + 5px right) + LINE_NUMBER_GUTTER_WIDTH = digitWidth + 10 + GutterIconRenderer.ICON_GUTTER_WIDTH; // 10px padding + icon space } // Draw outer border around entire area int offset = fullscreen() ? 2 : 1; drawRect(x - offset, y - offset - searchBar.getTotalHeight(), x + width + offset, y + height + offset, - 0xffa0a0a0); + 0xffa0a0a0); int searchHeight = searchBar.getTotalHeight(); // Draw line number gutter background + int viewportX = x + LINE_NUMBER_GUTTER_WIDTH; drawRect(x, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff000000); // Draw text viewport background (starts after gutter) drawRect(x + LINE_NUMBER_GUTTER_WIDTH, y, x + width, y + height, 0xff000000); // Draw separator line between gutter and text area - drawRect(x + LINE_NUMBER_GUTTER_WIDTH - 1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff3c3f41); + drawRect(x + LINE_NUMBER_GUTTER_WIDTH-1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff3c3f41); // Enable scissor test to clip drawing to the TEXT viewport rectangle (excludes gutter) GL11.glEnable(GL11.GL_SCISSOR_TEST); @@ -504,27 +615,32 @@ public void drawTextBox(int xMouse, int yMouse) { int maxScroll = Math.max(0, getPaddedLineCount() - container.visibleLines); - // Handle mouse wheel scroll - only consume when mouse is over this text area - boolean isMouseOverTextArea = xMouse >= x && xMouse < x + width && yMouse >= y && yMouse < y + height; - int wheelDelta = 0; - if (isMouseOverTextArea) { - wheelDelta = Mouse.getDWheel(); - if (listener instanceof GuiNPCInterface) { - ((GuiNPCInterface) listener).mouseScroll = wheelDelta; + // Handle mouse wheel scroll + int wheelDelta = ((GuiNPCInterface) listener).mouseScroll = Mouse.getDWheel(); + if (listener instanceof GuiNPCInterface) { + ((GuiNPCInterface) listener).mouseScroll = wheelDelta; + + // Let autocomplete menu consume scroll first if visible + if (wheelDelta != 0 && autocompleteManager.isVisible() && autocompleteManager.mouseScrolled(xMouse, yMouse, wheelDelta)) { + // Autocomplete consumed the scroll + } else { + boolean canScroll = !KEYS_OVERLAY.isVisible() || KEYS_OVERLAY.isVisible() && !KEYS_OVERLAY.aboveOverlay; + if (wheelDelta != 0 && canScroll) + scroll.applyWheelScroll(wheelDelta, maxScroll); } - boolean canScroll = !KEYS_OVERLAY.isVisible() || KEYS_OVERLAY.isVisible() && !KEYS_OVERLAY.aboveOverlay; - if (wheelDelta != 0 && canScroll) - scroll.applyWheelScroll(wheelDelta, maxScroll); } // Handle scrollbar dragging (delegated to ScrollState) if (scroll.isClickScrolling()) scroll.handleClickScrolling(yMouse, x, y, height, container.visibleLines, getPaddedLineCount(), maxScroll); - + // Update scroll animation scroll.initializeIfNeeded(scroll.getScrolledLine()); scroll.update(maxScroll); + // Update hover state for token tooltips + updateHoverState(xMouse, yMouse); + // Handle click-dragging for selection if (clicked) { clicked = Mouse.isButtonDown(0); @@ -535,7 +651,7 @@ public void drawTextBox(int xMouse, int yMouse) { doubleClicked = false; tripleClicked = false; } - setCursor(i, true); + setCursor(i, true); } } else if (doubleClicked || tripleClicked) { doubleClicked = false; @@ -546,8 +662,8 @@ public void drawTextBox(int xMouse, int yMouse) { // Calculate braces next to cursor to highlight int startBracket = 0, endBracket = 0; if (selection.getStartSelection() >= 0 && text != null && text.length() > 0 && - (selection.getEndSelection() - selection.getStartSelection() == 1 || !selection.hasSelection())) { - int[] span = BracketMatcher.findBracketSpanAt(text, selection.getStartSelection()); + (selection.getEndSelection() - selection.getStartSelection() == 1 || !selection.hasSelection())) { + int[] span = BracketMatcher.findBracketSpanAt(text,selection.getStartSelection()); if (span != null) { startBracket = span[0]; endBracket = span[1]; @@ -555,12 +671,12 @@ public void drawTextBox(int xMouse, int yMouse) { } List list = new ArrayList<>(container.lines); - + // Build brace spans: {origDepth, open line, close line, adjustedDepth} List braceSpans = BracketMatcher.computeBraceSpans(text, list); // Always highlight unmatched braces (positions in text) List unmatchedBraces = BracketMatcher.findUnmatchedBracePositions(text); - + // Determine which exact brace span (openLine/closeLine) to highlight indent guides for int highlightedOpenLine = -1; int highlightedCloseLine = -1; @@ -629,25 +745,25 @@ public void drawTextBox(int xMouse, int yMouse) { float fracPixels = (float) (fracOffset * container.lineHeight); GL11.glPushMatrix(); GL11.glTranslatef(0.0f, -fracPixels, 0.0f); - - + + // Expand render range by one line above/below so partially-visible lines are drawn int renderStart = Math.max(0, scroll.getScrolledLine() - 1); // Compute the last line index to render, including the last partially-visible line if any. // Adds the fractional scroll offset (fracOffset * lineHeight) to ensure the bottom line is drawn // when only part of it is visible in the viewport. int renderEnd = (int) Math.min(list.size() - 1, - scroll.getScrolledLine() + container.visibleLines + fracPixels + 1); + scroll.getScrolledLine() + container.visibleLines + fracPixels + 1); // Strings start drawing vertically this much into the line. int stringYOffset = 2; - + // Render LINE GUTTER numbers for (int i = renderStart; i <= renderEnd; i++) { int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight + stringYOffset; String lineNum = "" + (i + 1); int lineNumWidth = ClientProxy.Font.width(lineNum); - int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5; // right-align with 5px padding + int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5 - GutterIconRenderer.ICON_GUTTER_WIDTH; // right-align before icon space int lineNumY = posY + 1; // Highlight current line number int lineNumColor = 0xFF606366; @@ -664,10 +780,11 @@ public void drawTextBox(int xMouse, int yMouse) { } ClientProxy.Font.drawString(lineNum, lineNumX, lineNumY, lineNumColor); } - + // Render Viewport for (int i = renderStart; i <= renderEnd; i++) { LineData data = list.get(i); + ScriptLine scriptLine = container.getDocument().getLine(i); String line = data.text; int w = line.length(); // Use integer Y relative to scrolledLine; fractional offset applied via GL translate @@ -709,7 +826,7 @@ public void drawTextBox(int xMouse, int yMouse) { } } } - + // Highlight search matches if (searchBar.isVisible()) { List searchMatches = searchBar.getMatches(); @@ -758,8 +875,8 @@ public void drawTextBox(int xMouse, int yMouse) { if (occStart <= occEnd) { // Changed from < to <= to handle empty case int s = ClientProxy.Font.width(line.substring(0, occStart)); int e = isEmpty ? s + 2 : ClientProxy.Font.width( - line.substring(0, occEnd)) + 1; // 2px wide for empty - int occX = x + LINE_NUMBER_GUTTER_WIDTH + s; + line.substring(0, occEnd)) + 1; // 2px wide for empty + int occX = x + LINE_NUMBER_GUTTER_WIDTH + s; int occEndX = x + LINE_NUMBER_GUTTER_WIDTH + 2 + e; boolean isPrimary = renameHandler.isPrimaryOccurrence(occ[0]); @@ -772,12 +889,12 @@ public void drawTextBox(int xMouse, int yMouse) { int borderColor = 0xDDFFFFFF; //RED BG drawRect(occX, posY, occEndX, posY + container.lineHeight, 0x33ff0000); - + // Top border drawRect(occX, posY, occEndX, posY + 1, borderColor); - // Bottom border + // Bottom border drawRect(occX, posY + container.lineHeight - 1, occEndX, - posY + container.lineHeight, borderColor); + posY + container.lineHeight, borderColor); // Left border drawRect(occX, posY, occX + 1, posY + container.lineHeight, borderColor); // Right border @@ -789,7 +906,7 @@ public void drawTextBox(int xMouse, int yMouse) { String currentWord = renameHandler.getCurrentWord(); if (currentWord != null && cursorInWord >= 0 && cursorInWord <= currentWord.length()) { String beforeCursor = currentWord.substring(0, - Math.min(cursorInWord, currentWord.length())); + Math.min(cursorInWord, currentWord.length())); int cursorX = occX + ClientProxy.Font.width(beforeCursor); // drawRect(cursorX, posY + 1, cursorX + 1, posY + container.lineHeight - 1, // 0xFFFFFFFF); @@ -800,18 +917,18 @@ public void drawTextBox(int xMouse, int yMouse) { } } } - + // Highlight the current line (light gray) under any selection if (active && isEnabled() && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { - drawRect(x, posY, x + width - 1, posY + container.lineHeight, 0x22e0e0e0); + drawRect(x , posY, x + width - 1, posY + container.lineHeight, 0x22e0e0e0); } // Highlight selection if (selection.hasSelection() && selection.getEndSelection() > data.start && selection.getStartSelection() <= data.end) { if (selection.getStartSelection() < data.end) { int s = ClientProxy.Font.width( - line.substring(0, Math.max(selection.getStartSelection() - data.start, 0))); + line.substring(0, Math.max(selection.getStartSelection() - data.start, 0))); int e = ClientProxy.Font.width( - line.substring(0, Math.min(selection.getEndSelection() - data.start, w))) + 1; + line.substring(0, Math.min(selection.getEndSelection() - data.start, w))) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x992172ff); } } @@ -848,30 +965,43 @@ public void drawTextBox(int xMouse, int yMouse) { boolean highlighted = (openLine == highlightedOpenLine && closeLine == highlightedCloseLine); int guideColor = highlighted ? 0x9933cc00 : 0x33FFFFFF; - + int topY = y + (drawStart - scroll.getScrolledLine()) * container.lineHeight; int bottomY = y + (endLine - scroll.getScrolledLine() + 1) * container.lineHeight; - if (highlighted) - bottomY -= 2; + if(highlighted) + bottomY-=2; drawRect(gx, topY, gx + 1, bottomY, guideColor); } } int yPos = posY + stringYOffset; - data.drawString(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0); + + //data.drawString(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0); + + //scriptLine.drawString(x+LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0); + scriptLine.drawStringHex(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos); // Draw cursor: pause blinking while user is active recently boolean recentInput = selection.hadRecentInput(); if (active && isEnabled() && (recentInput || (cursorCounter / 10) % 2 == 0) && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { int posX = x + LINE_NUMBER_GUTTER_WIDTH + ClientProxy.Font.width( - line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length()))); - drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, 0xffffffff); + line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length()))); + drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, 0xffffffff); } } } + + // Render gutter icons for method override/implements + if (container != null && container.getDocument() != null) { + hoveredGutterMethod = GutterIconRenderer.renderIcons(container.lineHeight, + x + LINE_NUMBER_GUTTER_WIDTH - GutterIconRenderer.ICON_GUTTER_WIDTH + 1, y, renderStart, renderEnd, + scroll.getScrolledLine(), stringYOffset, container.getDocument().getAllMethods(), container.lines, + xMouse, yMouse, fracPixels); + } + GL11.glPopMatrix(); GL11.glDisable(GL11.GL_SCISSOR_TEST); - + if (hasVerticalScrollbar()) { Minecraft.getMinecraft().renderEngine.bindTexture(GuiCustomScroll.resource); int effLines = Math.max(1, getPaddedLineCount()); @@ -884,12 +1014,33 @@ public void drawTextBox(int xMouse, int yMouse) { drawRect(posX, posY, posX + 5, posY + sbSize + 2, 0xFFe0e0e0); } + if (parent != null) + parent.fullscreenButton.draw(xMouse, yMouse); + // Draw search/replace bar (overlays viewport) searchBar.draw(xMouse, yMouse); - + // Draw go to line dialog (overlays everything) goToLineDialog.draw(xMouse, yMouse); + KEYS_OVERLAY.draw(xMouse, yMouse, wheelDelta); + + // Draw autocomplete menu (overlays code area) + autocompleteManager.draw(xMouse, yMouse); + + // Draw hover tooltips (on top of everything) + if (hoverState.isTooltipVisible()) { + int xOffset = hasVerticalScrollbar() ? -8 : -2; + int viewportWidth = width - LINE_NUMBER_GUTTER_WIDTH; + int viewportY = y; + int viewportHeight = height; + TokenHoverRenderer.render(hoverState, viewportX, viewportWidth+xOffset, viewportY, viewportHeight); + } + + // Draw gutter icon tooltip + if (hoveredGutterMethod != null) { + GutterIconRenderer.renderTooltip(hoveredGutterMethod, xMouse, yMouse, x, width, y, height); + } } private void scissorViewport() { @@ -902,6 +1053,7 @@ private void scissorViewport() { int scissorH = this.height * scaleFactor; GL11.glScissor(scissorX, scissorY, scissorW, scissorH); } + // ==================== SELECTION & CURSOR POSITION ==================== // Get cursor position from mouse coordinates @@ -913,13 +1065,13 @@ private int getSelectionPos(int xMouse, int yMouse) { // visible lines (fractional positions) correctly hit that line. double fracPixels = scroll.getFractionalOffset() * container.lineHeight; double yMouseD = yMouse + fracPixels; - + ArrayList list = new ArrayList(this.container.lines); for (int i = 0; i < list.size(); ++i) { LineData data = (LineData) list.get(i); //+1 to account for the fractional line - if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines + 1) { + if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines +1) { double yPos = (i - scroll.getScrolledLine()) * this.container.lineHeight; if (yMouseD >= yPos && yMouseD < yPos + this.container.lineHeight) { int lineWidth = 0; @@ -952,13 +1104,98 @@ private int getSelectionPos(int xMouse, int yMouse) { private int getCursorLineIndex() { return selection.getCursorLineIndex(container.lines, text != null ? text.length() : 0); } + + /** + * Get the token at a specific screen position (mouse coordinates). + * Also returns the token's screen position and dimensions for tooltip placement. + * + * @param xMouse Screen X coordinate + * @param yMouse Screen Y coordinate + * @return Array of [Token, tokenScreenX, tokenScreenY, tokenWidth] or null if no token + */ + private Object[] getTokenAtScreenPosition(int xMouse, int yMouse) { + if (container == null || !(container instanceof ScriptTextContainer)) { + return null; + } + + ScriptTextContainer scriptContainer = (ScriptTextContainer) container; + + // Check if mouse is within the text viewport + int viewportX = x + LINE_NUMBER_GUTTER_WIDTH + 1; + if (xMouse < viewportX || xMouse > x + width || yMouse < y || yMouse > y + height) { + return null; + } + + // Adjust mouse position relative to text area + int relativeY = yMouse - y; - + // Account for fractional scrolling + double fracOffset = scroll.getFractionalOffset(); + double fracPixels = fracOffset * container.lineHeight; + double adjustedY = relativeY + fracPixels; + + // Find which line the mouse is over + int lineIdx = scroll.getScrolledLine() + (int)(adjustedY / container.lineHeight); + if (lineIdx < 0 || lineIdx >= container.lines.size()) { + return null; + } + + ScriptLine lineData = container.getDocument().getLine(lineIdx); + String lineText = lineData.getText(); + int lineStart = lineData.getGlobalStart(); + + // Get the token at this position + int globalMouseX = getSelectionPos(xMouse,yMouse); + Token token = lineData.getTokenAt(globalMouseX, (t) -> t.getType() != TokenType.DEFAULT); // Ignore default tokens i.e. whitespaces + if (token == null) + return null; + + // Calculate token's screen position + int tokenLocalStart = token.getGlobalStart() - lineStart; + int tokenLocalEnd = token.getGlobalEnd() - lineStart; + tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length())); + tokenLocalEnd = Math.max(0, Math.min(tokenLocalEnd, lineText.length())); + + int tokenScreenX = viewportX + ClientProxy.Font.width(lineText.substring(0, tokenLocalStart)); + int tokenScreenY = y + (lineIdx - scroll.getScrolledLine()) * container.lineHeight - (int)fracPixels; + int tokenWidth = ClientProxy.Font.width(lineText.substring(tokenLocalStart, tokenLocalEnd)); + + return new Object[] { token, tokenScreenX, tokenScreenY, tokenWidth }; + } + + /** + * Update hover state based on current mouse position. + * Called every frame from drawTextBox. + */ + private void updateHoverState(int xMouse, int yMouse) { + // Don't show tooltips when not active, clicking, or when overlays are visible + if (!isEnabled() || clicked || searchBar.isVisible() || + goToLineDialog.isVisible() || KEYS_OVERLAY.isVisible() || renameHandler.isActive() || autocompleteManager.isVisible()) { + hoverState.clearHover(); + return; + } + + // Get token at current mouse position + Object[] tokenInfo = getTokenAtScreenPosition(xMouse, yMouse); + + if (tokenInfo != null) { + Token token = (Token) tokenInfo[0]; + int tokenScreenX = (Integer) tokenInfo[1]; + int tokenScreenY = (Integer) tokenInfo[2]; + int tokenWidth = (Integer) tokenInfo[3]; + + hoverState.update(xMouse, yMouse, token, tokenScreenX, tokenScreenY, tokenWidth); + } else { + hoverState.update(xMouse, yMouse, null, 0, 0, 0); + } + } + + // Scroll viewport to keep cursor visible (minimal adjustment, like IntelliJ) // Only scrolls if cursor is outside the visible area private void scrollToCursor() { if (container == null || container.lines == null || container.lines.isEmpty()) return; - + int lineIdx = getCursorLineIndex(); int visible = Math.max(1, container.visibleLines); int effectiveVisible = Math.max(1, visible - bottomPaddingLines); @@ -1038,13 +1275,35 @@ private void initializeKeyBindings() { } }); - // COPY: Copy selection to clipboard + // COPY: Copy selection to clipboard (or entire line if no selection) KEYS.COPY.setTask(e -> { if (!e.isPress() || !isActive.get()) return; - if (selection.hasSelection()) + if (selection.hasSelection()) { NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); + lastCopyWasLine = false; + lastCopiedLineText = null; + } else { + // Copy entire current line (including newline) + int cursor = selection.getCursorPosition(); + LineData targetLine = null; + for (LineData line : container.lines) { + if (cursor >= line.start && cursor <= line.end) { + targetLine = line; + break; + } + } + + if (targetLine != null) { + int safeStart = Math.max(0, Math.min(targetLine.start, text.length())); + int safeEnd = Math.max(safeStart, Math.min(targetLine.end, text.length())); + String lineText = text.substring(safeStart, safeEnd); + NoppesStringUtils.setClipboardContents(lineText); + lastCopyWasLine = true; + lastCopiedLineText = lineText; + } + } }); // PASTE: Insert clipboard contents at caret @@ -1052,7 +1311,46 @@ private void initializeKeyBindings() { if (!e.isPress() || !isActive.get()) return; - addText(NoppesStringUtils.getClipboardContents()); + String clipboard = NoppesStringUtils.getClipboardContents(); + if (clipboard == null) + clipboard = ""; + + if (selection.hasSelection()) { + addText(clipboard); + lastCopyWasLine = false; + lastCopiedLineText = null; + scrollToCursor(); + return; + } + + boolean isLinePaste = lastCopyWasLine && lastCopiedLineText != null && clipboard.equals(lastCopiedLineText); + if (isLinePaste && container != null && container.lines != null) { + LineData currentLine = selection.findCurrentLine(container.lines); + if (currentLine != null) { + int insertPos = Math.max(0, Math.min(currentLine.end, text.length())); + String insertText = clipboard; + + // Ensure insertion happens on the line below + if (insertPos > 0 && text.charAt(insertPos - 1) != '\n') { + insertText = "\n" + insertText; + } + + // Ensure the inserted line doesn't merge with the following line + if (insertPos < text.length() && !insertText.endsWith("\n")) { + insertText = insertText + "\n"; + } + + String newText = text.substring(0, insertPos) + insertText + text.substring(insertPos); + setText(newText); + + int newCursor = insertPos + (insertText.startsWith("\n") ? 1 : 0); + selection.reset(Math.min(newCursor, newText.length())); + scrollToCursor(); + return; + } + } + + addText(clipboard); scrollToCursor(); }); @@ -1165,6 +1463,142 @@ private void initializeKeyBindings() { } }); + // DELETE_LINE: Delete the current line + KEYS.DELETE_LINE.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + if (text == null || text.isEmpty()) + return; + + int cursor = selection.getCursorPosition(); + + LineData targetLine = null; + int targetIndex = -1; + for (LineData line : container.lines) { + if (cursor >= line.start && cursor <= line.end) { + targetLine = line; + targetIndex = container.lines.indexOf(line); + break; + } + } + + if (targetLine == null) + return; + + int start = Math.max(0, Math.min(targetLine.start, text.length())); + int end = Math.max(start, Math.min(targetLine.end, text.length())); + + String newText = text.substring(0, start) + text.substring(end); + setText(newText); + + int newCursor = 0; + if (targetIndex > 0 && targetIndex <= container.lines.size() - 1) { + LineData previousLine = container.lines.get(targetIndex - 1); + int prevStart = Math.max(0, Math.min(previousLine.start, newText.length())); + int prevEnd = Math.max(prevStart, Math.min(previousLine.end, newText.length())); + int prevLen = Math.max(0, prevEnd - prevStart); + int offsetInLine = Math.max(0, Math.min(cursor - targetLine.start, prevLen)); + newCursor = Math.min(prevStart + offsetInLine, newText.length()); + } + + selection.reset(Math.min(newCursor, newText.length())); + scrollToCursor(); + }); + + // MOVE_LINE_UP: Move current line up + KEYS.MOVE_LINE_UP.setThrottleInterval(50).setTask(e -> { + if ((!e.isPress() && !e.isHold()) || !isActive.get()) + return; + + if (text == null || text.isEmpty()) + return; + + int tLen = text.length(); + int cursor = Math.max(0, Math.min(selection.getCursorPosition(), tLen)); + + // Work on real newline-delimited lines, not wrapped LineData. + int currStart = text.lastIndexOf('\n', Math.max(0, cursor - 1)); + currStart = currStart == -1 ? 0 : (currStart + 1); + if (currStart == 0) + return; // Can't move first line up + + int currEnd = text.indexOf('\n', cursor); + currEnd = currEnd == -1 ? tLen : (currEnd + 1); + + int prevEnd = currStart; + int prevStart = text.lastIndexOf('\n', Math.max(0, prevEnd - 2)); + prevStart = prevStart == -1 ? 0 : (prevStart + 1); + + String previousText = text.substring(prevStart, prevEnd); + String currentText = text.substring(currStart, currEnd); + + // If current line is the last line without a trailing newline, but it is being moved + // into the middle, ensure it ends with '\n' by transferring the '\n' from the previous line. + if (!currentText.endsWith("\n") && previousText.endsWith("\n") && !previousText.isEmpty()) { + currentText = currentText + "\n"; + previousText = previousText.substring(0, previousText.length() - 1); + } + + String before = text.substring(0, prevStart); + String after = text.substring(currEnd); + String newText = before + currentText + previousText + after; + setText(newText); + + int currentContentLen = currentText.endsWith("\n") ? Math.max(0, currentText.length() - 1) : currentText.length(); + int offsetInLine = Math.max(0, Math.min(cursor - currStart, currentContentLen)); + int newCursor = Math.min(prevStart + offsetInLine, newText.length()); + selection.reset(newCursor); + scrollToCursor(); + }); + + // MOVE_LINE_DOWN: Move current line down + KEYS.MOVE_LINE_DOWN.setThrottleInterval(50).setTask(e -> { + if ((!e.isPress() && !e.isHold()) || !isActive.get()) + return; + + if (text == null || text.isEmpty()) + return; + + int tLen = text.length(); + int cursor = Math.max(0, Math.min(selection.getCursorPosition(), tLen)); + + // Work on real newline-delimited lines, not wrapped LineData. + int currStart = text.lastIndexOf('\n', Math.max(0, cursor - 1)); + currStart = currStart == -1 ? 0 : (currStart + 1); + + int currEnd = text.indexOf('\n', cursor); + currEnd = currEnd == -1 ? tLen : (currEnd + 1); + if (currEnd >= tLen) + return; // Can't move last line down + + int nextStart = currEnd; + int nextEnd = text.indexOf('\n', nextStart); + nextEnd = nextEnd == -1 ? tLen : (nextEnd + 1); + + String currentText = text.substring(currStart, currEnd); + String nextText = text.substring(nextStart, nextEnd); + + // If the next line is the last line and doesn't end with '\n' (common when it contains + // only indentation spaces), swapping can merge whitespace onto the moved line. + // Fix by transferring the '\n' from currentText to nextText. + if (!nextText.endsWith("\n") && currentText.endsWith("\n") && !currentText.isEmpty()) { + currentText = currentText.substring(0, currentText.length() - 1); + nextText = nextText + "\n"; + } + + String before = text.substring(0, currStart); + String after = text.substring(nextEnd); + String newText = before + nextText + currentText + after; + setText(newText); + + int currentContentLen = currentText.endsWith("\n") ? Math.max(0, currentText.length() - 1) : currentText.length(); + int offsetInLine = Math.max(0, Math.min(cursor - currStart, currentContentLen)); + int newCursor = Math.min(currStart + nextText.length() + offsetInLine, newText.length()); + selection.reset(newCursor); + scrollToCursor(); + }); + // Check if can open just for SearchReplaceBar and GoToLine @@ -1173,27 +1607,27 @@ private void initializeKeyBindings() { KEYS.SEARCH.setTask(e -> { if (!e.isPress() || !openBoxes.get()) return; - + unfocusAll(); searchBar.openSearch(); }); - + // SEARCH_REPLACE: Open search+replace bar (Ctrl+Shift+R) // Works in search bar. KEYS.SEARCH_REPLACE.setTask(e -> { if (!e.isPress() || !openBoxes.get()) return; - + unfocusAll(); searchBar.openSearchReplace(); }); - + // GO_TO_LINE: Open go to line dialog (Ctrl+G) // Works in search bar. KEYS.GO_TO_LINE.setTask(e -> { if (!e.isPress() || !openBoxes.get()) return; - + unfocusAll(); goToLineDialog.toggle(); }); @@ -1210,6 +1644,14 @@ private void initializeKeyBindings() { renameHandler.startRename(); } }); + + // AUTOCOMPLETE: Trigger autocomplete (Ctrl+Space) + KEYS.AUTOCOMPLETE.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + autocompleteManager.triggerExplicit(); + }); } public void unfocusAll() { @@ -1217,6 +1659,8 @@ public void unfocusAll() { if (goToLineDialog.hasFocus()) goToLineDialog.unfocus(); if (renameHandler.isActive()) renameHandler.cancel(); + if (autocompleteManager.isVisible()) + autocompleteManager.dismiss(); } // ==================== KEYBOARD INPUT HANDLING ==================== @@ -1232,14 +1676,26 @@ public boolean textboxKeyTyped(char c, int i) { // Handle rename refactor input first if active if (renameHandler.isActive() && renameHandler.keyTyped(c, i)) return true; - + // Handle Go To Line dialog input first if it has focus - if (goToLineDialog.isVisible() && goToLineDialog.keyTyped(c, i)) - return true; - + if (goToLineDialog.isVisible() &&goToLineDialog.keyTyped(c, i)) + return true; + // Handle search bar input first if it has focus - if (searchBar.isVisible() && searchBar.keyTyped(c, i)) + if (searchBar.isVisible() && searchBar.keyTyped(c, i)) return true; + + // Handle autocomplete navigation keys first when visible + if (autocompleteManager.isVisible()) { + if (autocompleteManager.keyPressed(i)) { + return true; + } + } + + // Ignore if any global keys bound to this code are currently pressed + if (KEYS.hasMatchingKeyPressed(i)) + return false; + if (!active) return false; @@ -1268,20 +1724,31 @@ private boolean handleNavigationKeys(int i) { if (i == Keyboard.KEY_LEFT) { int j = 1; // default: move one character if (isCtrlKeyDown()) { - // When Ctrl is down, compute distance to previous word boundary. - // We match words in the text slice before the cursor and pick - // the last match start as the new boundary. - Matcher m = container.regexWord.matcher(text.substring(0, selection.getCursorPosition())); - while (m.find()) { - if (m.start() == m.end()) - continue; // skip empty matches - // j becomes the number of chars to move left to reach word start - j = selection.getCursorPosition() - m.start(); + int pos = selection.getCursorPosition(); + int g = pos; + java.util.function.IntPredicate isWordChar = ch -> Character.isLetterOrDigit(ch) || ch == '_'; + + if (pos > 0) { + char left = text.charAt(pos - 1); + if (Character.isWhitespace(left)) { + while (g - 1 >= 0 && Character.isWhitespace(text.charAt(g - 1))) g--; + } else if (isWordChar.test(left)) { + while (g - 1 >= 0 && isWordChar.test(text.charAt(g - 1))) g--; + } else { + while (g - 1 >= 0 && !Character.isWhitespace(text.charAt(g - 1)) && !isWordChar.test(text.charAt(g - 1))) g--; + } } + j = Math.max(1, pos - g); } int newPos = Math.max(selection.getCursorPosition() - j, 0); // If Shift is held, extend selection; otherwise place caret. setCursor(newPos, GuiScreen.isShiftKeyDown()); + + // Notify autocomplete of cursor movement + if (autocompleteManager.isVisible()) { + autocompleteManager.onCursorMove(text, newPos); + } + return true; } @@ -1289,26 +1756,30 @@ private boolean handleNavigationKeys(int i) { if (i == Keyboard.KEY_RIGHT) { int j = 1; // default: move one character if (isCtrlKeyDown()) { - String after = text.substring(selection.getCursorPosition()); - Matcher m = container.regexWord.matcher(after); - if (m.find()) { - if (m.start() == 0) { - // If the first match starts at 0 (cursor at word start), - // try to find the next match so we advance past the current word. - if (m.find()) - j = m.start(); - else - j = Math.max(1, after.length()); + int pos = selection.getCursorPosition(); + int end = pos; + java.util.function.IntPredicate isWordChar = ch -> Character.isLetterOrDigit(ch) || ch == '_'; + + if (pos < text.length()) { + char first = text.charAt(pos); + if (Character.isWhitespace(first)) { + while (end < text.length() && Character.isWhitespace(text.charAt(end))) end++; + } else if (isWordChar.test(first)) { + while (end < text.length() && isWordChar.test(text.charAt(end))) end++; } else { - j = m.start(); + while (end < text.length() && !Character.isWhitespace(text.charAt(end)) && !isWordChar.test(text.charAt(end))) end++; } - } else { - // No word match found after cursor -> jump to end - j = Math.max(1, after.length()); } + j = Math.max(1, end - pos); } int newPos = Math.min(selection.getCursorPosition() + j, text.length()); setCursor(newPos, GuiScreen.isShiftKeyDown()); + + // Notify autocomplete of cursor movement + if (autocompleteManager.isVisible()) { + autocompleteManager.onCursorMove(text, newPos); + } + return true; } @@ -1345,12 +1816,37 @@ private boolean handleInsertionKeys(int i) { return true; } - // RETURN/ENTER: special handling when preceding char is an opening brace '{' + // RETURN/ENTER: special handling for /** javadoc stub and opening brace '{' if (i == Keyboard.KEY_RETURN) { int cursorPos = selection.getCursorPosition(); + + // Check for /** javadoc stub auto-generation + String before = getSelectionBeforeText(); + if (before.endsWith("/**")) { + // Find current line to get indent level + String indent = ""; + for (LineData ld : this.container.lines) { + if (cursorPos >= ld.start && cursorPos <= ld.end) { + indent = ld.text.substring(0, IndentHelper.getLineIndent(ld.text)); + break; + } + } + + // Look ahead for a function declaration to generate smart JSDoc + String after = getSelectionAfterText(); + String javadocStub = generateJSDocStub(after, indent); + addText(javadocStub); + + // Position cursor after " * " on the description line + int newCursorPos = before.length() + 1 + indent.length() + 3; // +1 for \n, +3 for " * " + selection.reset(newCursorPos); + scrollToCursor(); + return true; + } + int prevNonWs = cursorPos - 1; while (prevNonWs >= 0 && prevNonWs < (text != null ? text.length() : 0) && Character.isWhitespace( - text.charAt(prevNonWs))) { + text.charAt(prevNonWs))) { prevNonWs--; } @@ -1365,7 +1861,6 @@ private boolean handleInsertionKeys(int i) { if (indent == null) indent = ""; String childIndent = indent + " "; - String before = getSelectionBeforeText(); String after = getSelectionAfterText(); int firstNewline = after.indexOf('\n'); @@ -1425,6 +1920,116 @@ private boolean handleInsertionKeys(int i) { return false; } + + /** + * Generates a JSDoc stub for a function declaration. + * Looks at the text after the cursor to find function signature and generates + * appropriate @param and @return tags. + * + * @param after Text after the /** to search for function + * @param indent Current line indentation + * @return The JSDoc stub string including newlines + */ + private String generateJSDocStub(String after, String indent) { + StringBuilder stub = new StringBuilder(); + stub.append("\n").append(indent).append(" * "); // Description line + + // Try to find a function/method declaration or field declaration following the JSDoc + // Skip whitespace and newlines + String trimmed = after.replaceFirst("^[\\s\\n\\r]*", ""); + + // First, try to match function/method pattern + java.util.regex.Pattern funcPattern = java.util.regex.Pattern.compile( + // Group 1: Optional return type (Java) or 'function' keyword (JS) + "^(?:(\\w+(?:<[^>]+>)?|function)\\s+)?" + + // Group 2: Method/function name + "(\\w+)\\s*" + + // Group 3: Parameters inside parentheses + "\\(([^)]*)\\)" + ); + + java.util.regex.Matcher funcMatcher = funcPattern.matcher(trimmed); + if (funcMatcher.find()) { + // Handle function/method + String returnOrKeyword = funcMatcher.group(1); + String funcName = funcMatcher.group(2); + String paramsStr = funcMatcher.group(3) != null ? funcMatcher.group(3).trim() : ""; + + // Determine if this is JavaScript (function keyword or no return type) + boolean isJS = "function".equals(returnOrKeyword) || returnOrKeyword == null || + (container != null && container.getDocument() != null && container.getDocument().isJavaScript()); + + // Parse parameters + if (!paramsStr.isEmpty()) { + String[] params = paramsStr.split(","); + for (String param : params) { + param = param.trim(); + if (param.isEmpty()) continue; + + String paramName; + String paramType = "any"; + + // Split on whitespace to get type and name + String[] parts = param.split("\\s+"); + if (parts.length >= 2) { + // Java style: Type name + paramType = parts[parts.length - 2]; + paramName = parts[parts.length - 1]; + } else { + // JS style: just name + paramName = parts[0]; + } + + // Remove any trailing array brackets or varargs + paramName = paramName.replaceAll("[\\[\\]\\.]", ""); + + if (isJS) { + stub.append("\n").append(indent).append(" * @param {").append(paramType).append("} ").append(paramName); + } else { + stub.append("\n").append(indent).append(" * @param ").append(paramName); + } + } + } + + // Add @return/@returns tag if applicable + if (returnOrKeyword != null && !"function".equals(returnOrKeyword) && !"void".equals(returnOrKeyword)) { + if (isJS) { + stub.append("\n").append(indent).append(" * @returns {").append(returnOrKeyword).append("}"); + } else { + stub.append("\n").append(indent).append(" * @return"); + } + } else if (isJS) { + // For JS functions without explicit return type, add empty @returns + stub.append("\n").append(indent).append(" * @returns {any}"); + } + } else { + // Try to match field declaration: [modifiers] Type name or var/let/const name + java.util.regex.Pattern fieldPattern = java.util.regex.Pattern.compile( + "^(?:(?:public|private|protected|static|final|var|let|const)\\s+)*" + // Optional modifiers + "(\\w+(?:<[^>]+>)?)\\s+" + // Type (Group 1) + "(\\w+)" + // Field name (Group 2) + "(?:\\s*=|\\s*;)" // Followed by = or ; + ); + + java.util.regex.Matcher fieldMatcher = fieldPattern.matcher(trimmed); + if (fieldMatcher.find()) { + String fieldType = fieldMatcher.group(1); + + // Check if this is JavaScript + boolean isJS = container != null && container.getDocument() != null && container.getDocument().isJavaScript(); + + if (isJS && fieldType != null) { + // For JS fields, add @type annotation + stub.append("\n").append(indent).append(" * @type {").append("any").append("}"); + } + } + } + + // Close the JSDoc block + stub.append("\n").append(indent).append(" */"); + + return stub.toString(); + } /** * Handles deletion keys: Delete, Backspace, and Ctrl+Backspace. @@ -1439,7 +2044,7 @@ private boolean handleDeletionKeys(int i) { if (!s.isEmpty() && !selection.hasSelection()) // remove single character after caret when nothing is selected s = s.substring(1); - setText(getSelectionBeforeText() + s); + setText(getSelectionBeforeText() + s, true); // Use atomic undo // Keep caret at same start selection selection.reset(selection.getStartSelection()); return true; @@ -1447,46 +2052,33 @@ private boolean handleDeletionKeys(int i) { // CTRL+BACKSPACE: delete to previous word or whitespace boundary. if (isKeyComboCtrlBackspace(i)) { - String s = getSelectionBeforeText(); + String before = getSelectionBeforeText(); if (selection.getStartSelection() > 0 && !selection.hasSelection()) { - int nearestCondition = selection.getCursorPosition(); - int g; - // If the char left of caret is whitespace, find the first non-space to the left; - // otherwise find first whitespace/newline to the left (word boundary). - boolean cursorInWhitespace = Character.isWhitespace(s.charAt(selection.getCursorPosition() - 1)); - if (cursorInWhitespace) { - // Scan left until non-whitespace (start of previous word) - for (g = selection.getCursorPosition() - 1; g >= 0; g--) { - char currentChar = s.charAt(g); - if (!Character.isWhitespace(currentChar)) { - nearestCondition = g; - break; - } - if (g == 0) { - nearestCondition = 0; - } - } + int pos = selection.getCursorPosition(); + int g = pos; + + // Helper: treat letters, digits and underscore as word characters + java.util.function.IntPredicate isWordChar = ch -> Character.isLetterOrDigit(ch) || ch == '_'; + + // If caret is after whitespace, delete contiguous whitespace first + char left = before.charAt(pos - 1); + if (Character.isWhitespace(left)) { + while (g - 1 >= 0 && Character.isWhitespace(before.charAt(g - 1))) + g--; + } else if (isWordChar.test(left)) { + // Delete contiguous word characters (letters/digits/_) + while (g - 1 >= 0 && isWordChar.test(before.charAt(g - 1))) + g--; } else { - // Scan left until whitespace/newline is found (word boundary) - for (g = selection.getCursorPosition() - 1; g >= 0; g--) { - char currentChar = s.charAt(g); - if (Character.isWhitespace(currentChar) || currentChar == '\n') { - nearestCondition = g; - break; - } - if (g == 0) { - nearestCondition = 0; - } - } + // Delete contiguous non-word, non-whitespace characters (punctuation) + while (g - 1 >= 0 && !Character.isWhitespace(before.charAt(g - 1)) && !isWordChar.test(before.charAt(g - 1))) + g--; } - // Trim the prefix up to the discovered boundary - s = s.substring(0, nearestCondition); - // Adjust selection start to match removed characters - selection.setStartSelection( - selection.getStartSelection() - (selection.getCursorPosition() - nearestCondition)); + before = before.substring(0, g); + selection.setStartSelection(selection.getStartSelection() - (pos - g)); } - setText(s + getSelectionAfterText()); + setText(before + getSelectionAfterText(), true); // Use atomic undo selection.reset(selection.getStartSelection()); return true; } @@ -1501,7 +2093,7 @@ private boolean handleDeletionKeys(int i) { // 1) selection deletion if (selection.hasSelection()) { String s = getSelectionBeforeText(); - setText(s + getSelectionAfterText()); + setText(s + getSelectionAfterText(), true); // Use atomic undo selection.reset(selection.getStartSelection()); scrollToCursor(); return true; @@ -1525,7 +2117,7 @@ private boolean handleDeletionKeys(int i) { } String before = text.substring(0, ValueUtil.clamp(currCheck.start - 1, 0, text.length())); String after = removeEnd <= text.length() ? text.substring(removeEnd) : ""; - setText(before + after); + setText(before + after, true); // Use atomic undo int newCursor = Math.max(0, currCheck.start - 1); selection.reset(newCursor); scrollToCursor(); @@ -1554,7 +2146,7 @@ private boolean handleDeletionKeys(int i) { } String before = text.substring(0, curr.start); String after = removeEnd <= text.length() ? text.substring(removeEnd) : ""; - setText(before + after); + setText(before + after, true); // Use atomic undo // Place caret at end of previous line int newCursor = Math.max(0, curr.start - 1); selection.reset(newCursor); @@ -1572,16 +2164,16 @@ private boolean handleDeletionKeys(int i) { char lastChar = before.charAt(before.length() - 1); char firstChar = content.charAt(0); // Avoid adding space when punctuation/brackets are adjacent - if (!Character.isWhitespace(lastChar) && + if (!Character.isWhitespace(lastChar) && lastChar != '{' && lastChar != '(' && lastChar != '[' && - firstChar != '}' && firstChar != ')' && firstChar != ']' && + firstChar != '}' && firstChar != ')' && firstChar != ']' && firstChar != ';' && firstChar != ',' && firstChar != '.' && firstChar != '\n') { spacer = " "; } } - setText(before + spacer + content); + setText(before + spacer + content, true); // Use atomic undo int newCursor = before.length() + spacer.length(); selection.reset(newCursor); scrollToCursor(); @@ -1595,15 +2187,15 @@ private boolean handleDeletionKeys(int i) { if (selection.getStartSelection() > 0 && selection.getStartSelection() < text.length()) { char prev = text.charAt(selection.getStartSelection() - 1); char nextc = text.charAt(selection.getStartSelection()); - if ((prev == '(' && nextc == ')') || - (prev == '[' && nextc == ']') || - (prev == '{' && nextc == '}') || - (prev == '\'' && nextc == '\'') || + if ((prev == '(' && nextc == ')') || + (prev == '[' && nextc == ']') || + (prev == '{' && nextc == '}') || + (prev == '\'' && nextc == '\'') || (prev == '"' && nextc == '"')) { String before = text.substring(0, selection.getStartSelection() - 1); String after = selection.getStartSelection() + 1 < text.length() ? text.substring( - selection.getStartSelection() + 1) : ""; - setText(before + after); + selection.getStartSelection() + 1) : ""; + setText(before + after, true); // Use atomic undo selection.setStartSelection(selection.getStartSelection() - 1); selection.reset(selection.getStartSelection()); scrollToCursor(); @@ -1615,9 +2207,11 @@ private boolean handleDeletionKeys(int i) { String s = getSelectionBeforeText(); s = s.substring(0, s.length() - 1); selection.setStartSelection(selection.getStartSelection() - 1); - setText(s + getSelectionAfterText()); + setText(s + getSelectionAfterText(), true); // Use atomic undo selection.reset(selection.getStartSelection()); scrollToCursor(); + // Notify autocomplete of deletion + autocompleteManager.onDeleteKey(text, selection.getCursorPosition()); return true; } @@ -1763,48 +2357,63 @@ private boolean handleCharacterInput(char c) { if (ChatAllowedCharacters.isAllowedCharacter(c)) { String before = getSelectionBeforeText(); String after = getSelectionAfterText(); + int cursorPos = selection.getCursorPosition(); // If the user types a closing character and that same closer is // already immediately after the caret, move caret past it instead // of inserting another closer. This prevents duplicate closers // when the editor auto-inserts pairs. - if ((c == ')' || c == ']' || c == '"' || c == '\'') && after.length() > 0 && after.charAt(0) == c) { + if ((c == ')' || c == ']' || c == '"' || c == '\'' ) && after.length() > 0 && after.charAt(0) == c) { // Move caret forward by one (skip over existing closer) selection.reset(before.length() + 1); scrollToCursor(); + // Notify autocomplete of the character + autocompleteManager.onCharTyped(c, text, cursorPos); return true; } // Auto-pair insertion: when opening a quote/brace/bracket is typed, // insert a matching closer and place caret between the pair. - if (c == '"') { - setText(before + "\"\"" + after); - selection.reset(before.length() + 1); - scrollToCursor(); - return true; - } - if (c == '\'') { - setText(before + "''" + after); - selection.reset(before.length() + 1); - scrollToCursor(); - return true; - } - if (c == '[') { - setText(before + "[]" + after); - selection.reset(before.length() + 1); - scrollToCursor(); - return true; - } - if (c == '(') { - setText(before + "()" + after); - selection.reset(before.length() + 1); - scrollToCursor(); - return true; + // But only if the current position is not excluded (e.g., inside a comment or string) + if (!container.getDocument().isExcludedInclusive(cursorPos)) { + if (c == '"') { + setText(before + "\"\"" + after, true); + selection.reset(before.length() + 1); + scrollToCursor(); + // Notify autocomplete of the character + autocompleteManager.onCharTyped(c, text, cursorPos); + return true; + } + if (c == '\'') { + setText(before + "''" + after, true); + selection.reset(before.length() + 1); + scrollToCursor(); + // Notify autocomplete of the character + autocompleteManager.onCharTyped(c, text, cursorPos); + return true; + } + if (c == '[') { + setText(before + "[]" + after, true); + selection.reset(before.length() + 1); + scrollToCursor(); + // Notify autocomplete of the character + autocompleteManager.onCharTyped(c, text, cursorPos); + return true; + } + if (c == '(') { + setText(before + "()" + after, true); + selection.reset(before.length() + 1); + scrollToCursor(); + // Notify autocomplete of the character + autocompleteManager.onCharTyped(c, text, cursorPos); + return true; + } } - // Default insertion for printable characters: insert at caret (replacing selection) addText(Character.toString(c)); scrollToCursor(); + // Notify autocomplete of the character + autocompleteManager.onCharTyped(c, text, cursorPos); return true; } return false; @@ -1816,10 +2425,10 @@ private boolean isShiftKeyDown() { // ==================== COMMENT TOGGLING ==================== // Uses CommentHandler helper for comment operations - + private void toggleCommentSelection() { CommentHandler.SelectionToggleResult result = CommentHandler.toggleCommentSelection( - text, container.lines, selection.getStartSelection(), selection.getEndSelection()); + text, container.lines, selection.getStartSelection(), selection.getEndSelection()); setText(result.newText); selection.setStartSelection(result.newStartSelection); selection.setEndSelection(result.newEndSelection); @@ -1827,15 +2436,15 @@ private void toggleCommentSelection() { private void toggleCommentLineAtCursor() { CommentHandler.SingleLineToggleResult result = CommentHandler.toggleCommentAtCursor( - text, container.lines, selection.getCursorPosition()); + text, container.lines, selection.getCursorPosition()); setText(result.newText); setCursor(result.newCursorPosition, false); } - - public boolean closeOnEsc() { - return !KEYS_OVERLAY.isVisible() && !searchBar.isVisible() && !goToLineDialog.isVisible() && !renameHandler.isActive(); + + public boolean closeOnEsc(){ + return !KEYS_OVERLAY.isVisible() && !searchBar.isVisible() && !goToLineDialog.isVisible() && !renameHandler.isActive() && !autocompleteManager.isVisible(); } - + // ==================== KEYBOARD MODIFIERS ==================== private boolean isAltKeyDown() { @@ -1883,7 +2492,7 @@ private String getAutoIndentForEnter() { return IndentHelper.getAutoIndentForEnter(currentLine.text, selection.getCursorPosition() - currentLine.start); } // ==================== TEXT FORMATTING ==================== - + private int getTabSize() { return IndentHelper.TAB_SIZE; } @@ -1899,7 +2508,7 @@ private void formatText() { setText(result.text); selection.reset(Math.max(0, Math.min(result.cursorPosition, this.text.length()))); } - + // ==================== TAB HANDLING ==================== private void handleTab() { @@ -1997,7 +2606,7 @@ private void handleShiftTab() { } } } - + // ==================== CURSOR MANAGEMENT ==================== private void setCursor(int i, boolean select) { @@ -2006,7 +2615,7 @@ private void setCursor(int i, boolean select) { private void addText(String s) { int insertPos = selection.getStartSelection(); - this.setText(this.getSelectionBeforeText() + s + this.getSelectionAfterText()); + this.setText(this.getSelectionBeforeText() + s + this.getSelectionAfterText(), true); // Use atomic undo for typing selection.afterTextInsert(insertPos + s.length()); } @@ -2025,32 +2634,103 @@ public String getSelectionBeforeText() { public String getSelectionAfterText() { return selection.getTextAfter(text); } - + // ==================== MOUSE HANDLING ==================== public void mouseClicked(int xMouse, int yMouse, int mouseButton) { + // Check autocomplete menu clicks first + if (autocompleteManager.isVisible() && autocompleteManager.mouseClicked(xMouse, yMouse, mouseButton)) { + return; + } + + // Dismiss autocomplete if clicking elsewhere + if (autocompleteManager.isVisible()) { + autocompleteManager.dismiss(); + } + + if (parent != null && parent.fullscreenButton.mouseClicked(xMouse, yMouse, mouseButton)) + return; + + // Check go to line dialog clicks first if (goToLineDialog.isVisible() && goToLineDialog.mouseClicked(xMouse, yMouse, mouseButton)) { return; } - + // Check search bar clicks first if (searchBar.isVisible() && searchBar.mouseClicked(xMouse, yMouse, mouseButton)) { return; } - + // If search bar is visible but click was outside it, unfocus the search bar if (searchBar.isVisible()) { searchBar.unfocus(); } - + // Let the overlay consume clicks (it returns true when it handled the event) if (KEYS_OVERLAY.mouseClicked(xMouse, yMouse, mouseButton)) return; - + // Determine whether click occurred inside the text area bounds this.active = xMouse >= this.x && xMouse < this.x + this.width && yMouse >= this.y && yMouse < this.y + this.height; if (this.active) { + // Ctrl+Click: Go to definition + if (mouseButton == 0 && isCtrlKeyDown()) { + Object[] tokenInfo = getTokenAtScreenPosition(xMouse, yMouse); + if (tokenInfo != null) { + Token token = (Token) tokenInfo[0]; + int targetOffset = -1; + + // Check if token has method info with declaration + if (token.getMethodInfo() != null) { + MethodInfo methodInfo = token.getMethodInfo(); + if (methodInfo.getNameOffset() >= 0) { + targetOffset = methodInfo.getNameOffset(); + } + } + // Check if token has method call info with resolved declaration + else if (token.getMethodCallInfo() != null) { + MethodCallInfo callInfo = token.getMethodCallInfo(); + MethodInfo resolvedMethod = callInfo.getResolvedMethod(); + if (resolvedMethod != null && resolvedMethod.getNameOffset() >= 0) { + targetOffset = resolvedMethod.getNameOffset(); + } + } + // Check if token has field info with declaration + else if (token.getFieldInfo() != null) { + FieldInfo fieldInfo = token.getFieldInfo(); + if (fieldInfo.getDeclarationOffset() >= 0) { + targetOffset = fieldInfo.getDeclarationOffset(); + } + } + // Check if token has field access info with resolved declaration + else if (token.getFieldAccessInfo() != null) { + FieldAccessInfo accessInfo = token.getFieldAccessInfo(); + FieldInfo resolvedField = accessInfo.getResolvedField(); + if (resolvedField != null && resolvedField.getDeclarationOffset() >= 0) { + targetOffset = resolvedField.getDeclarationOffset(); + } + } + // Check if token is a script-defined type + else if (token.getTypeInfo() != null && token.getTypeInfo() instanceof ScriptTypeInfo) { + ScriptTypeInfo scriptType = + (ScriptTypeInfo) token.getTypeInfo(); + if (scriptType.getDeclarationOffset() >= 0) { + targetOffset = scriptType.getDeclarationOffset(); + } + } + + // Jump to definition if found + if (targetOffset >= 0) { + selection.reset(targetOffset); + scrollToCursor(); + this.clicked = false; + activeTextfield = this; + return; // Consume the event + } + } + } + // Compute logical click position in text int clickPos = this.getSelectionPos(xMouse, yMouse); @@ -2078,7 +2758,7 @@ public void mouseClicked(int xMouse, int yMouse, int mouseButton) { if (this.clicked && getPaddedLineCount() * this.container.lineHeight > this.height && xMouse > this.x + this.width - 8) { // We consumed the mouse-down as a scrollbar drag start this.clicked = false; - scroll.startScrollbarDrag(yMouse, this.y, this.height, getPaddedLineCount()); + scroll.startScrollbarDrag(yMouse,this.y,this.height, getPaddedLineCount()); } else { // Handle double/triple click selection counting if (time - this.lastClicked < 300L) { @@ -2108,6 +2788,29 @@ public void mouseClicked(int xMouse, int yMouse, int mouseButton) { this.lastClicked = time; activeTextfield = this; + + // Click-to-pin handling: if enabled, clicking a token will pin/unpin its tooltip + if (mouseButton == 0 && hoverState.isClickToPinEnabled()) { + Object[] tokenInfo = getTokenAtScreenPosition(xMouse, yMouse); + if (tokenInfo != null) { + Token clickedToken = (Token) tokenInfo[0]; + int tokenScreenX = (Integer) tokenInfo[1]; + int tokenScreenY = (Integer) tokenInfo[2]; + int tokenWidth = (Integer) tokenInfo[3]; + + // If already pinned on same token, unpin; otherwise pin this token + if (hoverState.isPinned() && hoverState.getHoveredToken() == clickedToken) { + hoverState.unpin(); + } else { + hoverState.pinToken(clickedToken, tokenScreenX, tokenScreenY, tokenWidth); + } + } else { + // Clicked outside any token -> unpin any pinned tooltip + if (hoverState.isPinned()) { + hoverState.unpin(); + } + } + } } } @@ -2122,10 +2825,20 @@ public void updateCursorCounter() { renameHandler.updateCursor(); ++this.cursorCounter; } - + // ==================== TEXT MANAGEMENT ==================== public void setText(String text) { + setText(text, false); + } + + /** + * Set text with optional atomic undo support. + * + * @param text The new text content + * @param allowAtomic If true, allows grouping consecutive typing into single undo steps + */ + private void setText(String text, boolean allowAtomic) { if (text == null) { return; } @@ -2139,19 +2852,53 @@ public void setText(String text) { this.listener.textUpdate(text); } - if (!this.undoing) { + // Atomic undo logic: group consecutive typing into word-based undo steps + if (!this.undoing && allowAtomic && !undoList.isEmpty()) { + long now = System.currentTimeMillis(); + int cursorPos = selection.getCursorPosition(); + + // Check if we should merge with the previous undo entry + boolean shouldMerge = + (now - lastTypingTime < 2000) && // Within 2 seconds + Math.abs(cursorPos - lastTypingPos) <= 1; // Contiguous edit + + // If typing a space or newline, break the undo group + if (shouldMerge && text.length() > 0 && cursorPos > 0 && cursorPos <= text.length()) { + char lastChar = text.charAt(cursorPos - 1); + if (Character.isWhitespace(lastChar)) { + shouldMerge = false; + } + } + + if (shouldMerge) { + // Don't add a new undo entry - just update the text + // The existing undo entry will remain unchanged + } else { + // Add new undo entry + this.undoList.add(new GuiScriptTextArea.UndoData(this.text, selection.getCursorPosition())); + this.redoList.clear(); + } + + lastTypingTime = now; + lastTypingPos = cursorPos; + } else if (!this.undoing) { + // Normal undo entry (non-atomic) this.undoList.add(new GuiScriptTextArea.UndoData(this.text, selection.getCursorPosition())); this.redoList.clear(); + + // Reset atomic undo tracking + lastTypingTime = System.currentTimeMillis(); + lastTypingPos = selection.getCursorPosition(); } this.text = text; //this.container = new TextContainer(text); if (this.container == null) - this.container = new JavaTextContainer(text); + this.container = new ScriptTextContainer(text); this.container.init(text, this.width, this.height); - if (this.enableCodeHighlighting) + if (this.enableCodeHighlighting) this.container.formatCodeText(); // Ensure scroll state stays in bounds after text change @@ -2163,6 +2910,9 @@ public void setText(String text) { // Consider text changes user activity to pause caret blinking briefly selection.markActivity(); searchBar.updateMatches(); + + // Update autocomplete manager with current container + autocompleteManager.setContainer(this.container); } } @@ -2183,6 +2933,26 @@ public void enableCodeHighlighting() { this.enableCodeHighlighting = true; this.container.formatCodeText(); } + + /** + * Set the scripting language for syntax highlighting and type inference. + * @param language The language name (e.g., "ECMAScript", "Groovy") + */ + public void setLanguage(String language) { + if (this.container != null) { + this.container.setLanguage(language); + if (this.enableCodeHighlighting) { + this.container.formatCodeText(); + } + } + } + + /** + * Get the current scripting language. + */ + public String getLanguage() { + return this.container != null ? this.container.getLanguage() : "ECMAScript"; + } /** * Set the script context (NPC, PLAYER, BLOCK, ITEM, etc.). @@ -2192,7 +2962,7 @@ public void enableCodeHighlighting() { */ public void setScriptContext(ScriptContext context) { if (this.container != null) { - // this.container.setScriptContext(context); + this.container.setScriptContext(context); } } @@ -2202,8 +2972,23 @@ public void setScriptContext(ScriptContext context) { * @return The script context (NPC, PLAYER, BLOCK, ITEM, etc.) */ public ScriptContext getScriptContext() { - // return this.container != null ? this.container.getScriptContext() : ScriptContext.GLOBAL; - return ScriptContext.GLOBAL; + return this.container != null ? this.container.getScriptContext() : ScriptContext.GLOBAL; + } + + public ScriptTextContainer getContainer() { + return this.container; + } + + /** + * Add implicit imports that should be resolved without explicit import statements. + * Used for JaninoScript default imports and hook parameter types. + * + * @param patterns Array of import patterns to add (wildcard packages like "noppes.npcs.api.*" or FQ class names) + */ + public void addImplicitImports(String... patterns) { + if (this.container != null) { + this.container.addImplicitImports(patterns); + } } public void setListener(ITextChangeListener listener) { @@ -2213,7 +2998,164 @@ public void setListener(ITextChangeListener listener) { private void clampSelectionBounds() { selection.clamp(text != null ? text.length() : 0); } + + // ==================== AUTOCOMPLETE VISIBILITY ==================== + + /** + * Check if a click position is within the bounds of the autocomplete menu. + * Returns false if autocomplete is not visible. + */ + public boolean isPointOnAutocompleteMenu(int mouseX, int mouseY) { + if (autocompleteManager == null || !autocompleteManager.isVisible()) { + return false; + } + + + AutocompleteMenu menu = autocompleteManager.getMenu(); + if (menu == null) { + return false; + } + + int menuX = menu.getX(); + int menuY = menu.getY(); + int menuWidth = menu.getWidth(); + int menuHeight = menu.getHeight(); + + return mouseX >= menuX && mouseX <= menuX + menuWidth && + mouseY >= menuY && mouseY <= menuY + menuHeight; + } + + // ==================== AUTO-IMPORT ==================== + + /** + * Add an import statement and sort all imports. + */ + private void addAndSortImport(String importPath) { + String currentText = this.text; + int savedCursorPos = selection.getCursorPosition(); + + // Find all existing imports + java.util.regex.Pattern importPattern = java.util.regex.Pattern.compile( + "(?m)^\\s*import\\s+(?:static\\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_*][A-Za-z0-9_]*)*)\\s*;\\s*$" + ); + java.util.regex.Matcher matcher = importPattern.matcher(currentText); + + java.util.List imports = new java.util.ArrayList<>(); + int firstImportStart = -1; + int lastImportEnd = -1; + + while (matcher.find()) { + String importStatement = matcher.group(0); + String importPathFound = matcher.group(1).replaceAll("\\s+", ""); + + if (firstImportStart == -1) { + firstImportStart = matcher.start(); + } + lastImportEnd = matcher.end(); + + // Skip if this is the import we're trying to add + if (!importPathFound.equals(importPath)) { + imports.add(new ImportEntry(importPathFound, importStatement.trim())); + } + } + + // Add the new import + imports.add(new ImportEntry(importPath, "import " + importPath + ";")); + + // Sort imports + java.util.Collections.sort(imports, new java.util.Comparator() { + @Override + public int compare(ImportEntry a, ImportEntry b) { + // Sort order: java.*, javax.*, then others alphabetically + boolean aIsJava = a.path.startsWith("java."); + boolean aIsJavax = a.path.startsWith("javax."); + boolean bIsJava = b.path.startsWith("java."); + boolean bIsJavax = b.path.startsWith("javax."); + + if (aIsJava && !bIsJava) return -1; + if (!aIsJava && bIsJava) return 1; + if (aIsJavax && !bIsJavax && !bIsJava) return -1; + if (!aIsJavax && bIsJavax && !aIsJava) return 1; + + return a.path.compareTo(b.path); + } + }); + + // Build the new import block + StringBuilder importBlock = new StringBuilder(); + String prevPackage = ""; + for (ImportEntry entry : imports) { + // Add blank line between different top-level packages + String topPackage = entry.path.contains(".") ? + entry.path.substring(0, entry.path.indexOf('.')) : entry.path; + if (!prevPackage.isEmpty() && !topPackage.equals(prevPackage)) { + importBlock.append("\n"); + } + importBlock.append(entry.statement).append("\n"); + prevPackage = topPackage; + } + + // Determine where to insert/replace imports + String newText; + int cursorAdjustment = 0; + + if (firstImportStart != -1) { + // Replace existing import block + String before = currentText.substring(0, firstImportStart); + String after = currentText.substring(lastImportEnd); + newText = before + importBlock.toString() + after; + + // Adjust cursor if it's after the import block + int newImportEnd = firstImportStart + importBlock.length(); + if (savedCursorPos >= lastImportEnd) { + cursorAdjustment = newImportEnd - lastImportEnd; + } + } else { + // No existing imports - add at top after package statement (if any) + java.util.regex.Pattern packagePattern = java.util.regex.Pattern.compile( + "(?m)^\\s*package\\s+[A-Za-z_][A-Za-z0-9_.]*\\s*;\\s*$" + ); + java.util.regex.Matcher pkgMatcher = packagePattern.matcher(currentText); + + int insertPos = 0; + if (pkgMatcher.find()) { + insertPos = pkgMatcher.end(); + // Add blank line after package + newText = currentText.substring(0, insertPos) + "\n" + + importBlock.toString() + "\n" + currentText.substring(insertPos); + cursorAdjustment = importBlock.length() + 2; // +2 for the newlines + } else { + // Insert at very beginning + newText = importBlock.toString() + "\n" + currentText; + cursorAdjustment = importBlock.length() + 1; + } + } + + // Apply the changes + setText(newText); + selection.reset(savedCursorPos + cursorAdjustment); + scrollToCursor(); + } + + public void formatCodeText() { + if (this.enableCodeHighlighting && this.container != null) { + this.container.formatCodeText(); + } + } + /** + * Helper class for tracking imports during sorting. + */ + private static class ImportEntry { + String path; + String statement; + + ImportEntry(String path, String statement) { + this.path = path; + this.statement = statement; + } + } + // ==================== INNER CLASSES ==================== public static class UndoData { diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea1.java b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea1.java new file mode 100644 index 000000000..60e5a38a0 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea1.java @@ -0,0 +1,2186 @@ +package noppes.npcs.client.gui.util; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.gui.ScaledResolution; +import net.minecraft.util.ChatAllowedCharacters; +import noppes.npcs.NoppesStringUtils; +import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.gui.script.GuiScriptInterface; +import noppes.npcs.client.gui.util.key.OverlayKeyPresetViewer; +import noppes.npcs.client.gui.util.script.*; +import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import noppes.npcs.client.gui.util.script.interpreter.ScriptLine; +import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer; +import noppes.npcs.client.key.impl.ScriptEditorKeys; +import noppes.npcs.util.ValueUtil; +import org.lwjgl.input.Keyboard; +import org.lwjgl.input.Mouse; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import java.util.regex.Matcher; + +import static net.minecraft.client.gui.GuiScreen.isCtrlKeyDown; + +/** + * Script text editor component with syntax highlighting, bracket matching, + * smooth scrolling, and IDE-like features. + * + * Helper classes used: + * - ScrollState: smooth scroll animation and state management + * - SelectionState: cursor position and text selection management + * - BracketMatcher: bracket matching and brace span computation + * - IndentHelper: indentation utilities and text formatting + * - CommentHandler: line comment toggling + * - CursorNavigation: cursor movement logic + */ +public class GuiScriptTextArea1 extends GuiNpcTextField { + + // ==================== DIMENSIONS & POSITION ==================== + public int x; + public int y; + + // ==================== STATE FLAGS ==================== + public boolean active = false; + public boolean enabled = true; + public boolean visible = true; + public boolean clicked = false; + public boolean doubleClicked = false; + public boolean tripleClicked = false; + private int clickCount = 0; + private long lastClicked = 0L; + + // ==================== TEXT & CONTAINER ==================== + public String text = null; + public String highlightedWord; + private ScriptTextContainer container = null; + private boolean enableCodeHighlighting = false; + // Extra empty lines to allow padding at the bottom of the editor viewport + private int bottomPaddingLines = 6; + + // Search bar layout tracking to allow idempotent/resilient resizing + private int searchBaseY = 0; + private int searchBaseHeight = 0; + private int searchAppliedOffset = 0; + private boolean searchBaseInitialized = false; + + private int getPaddedLineCount() { + if (container == null) return 0; + // Only add bottom padding when the content is already scrollable. This avoids + // introducing a scrollbar when there's nothing to scroll for. + if (container.linesCount > container.visibleLines - bottomPaddingLines) { + return Math.max(0, container.linesCount + bottomPaddingLines); + } else { + return container.linesCount; + } + } + + // ==================== HELPER CLASS INSTANCES ==================== + private final ScrollState scroll = new ScrollState(); + private final SelectionState selection = new SelectionState(); + + // ==================== UI COMPONENTS ==================== + private int cursorCounter; + private ITextChangeListener listener; + private static int LINE_NUMBER_GUTTER_WIDTH = 25; + + // ==================== UNDO/REDO ==================== + public List undoList = new ArrayList<>(); + public List redoList = new ArrayList<>(); + public boolean undoing = false; + + // ==================== KEYS ==================== + public static final ScriptEditorKeys KEYS = new ScriptEditorKeys(); + public OverlayKeyPresetViewer KEYS_OVERLAY = new OverlayKeyPresetViewer(KEYS); + + // ==================== SEARCH/REPLACE ==================== + public static final SearchReplaceBar searchBar = new SearchReplaceBar(); + + // ==================== GO TO LINE ==================== + private final GoToLineDialog goToLineDialog = new GoToLineDialog(); + + // ==================== RENAME REFACTOR ==================== + private final RenameRefactorHandler renameHandler = new RenameRefactorHandler(); + + // ==================== CONSTRUCTOR ==================== + + public GuiScriptTextArea1(GuiScreen guiScreen, int id, int x, int y, int width, int height, String text) { + super(id, guiScreen, x, y, width, height, null); + init(x, y, width, height, text); + } + + public void init(int x, int y, int width, int height, String text) { + this.x = xPosition = x; + this.y = yPosition = y; + this.width = width; + this.height = height; + this.undoing = true; + this.setText(text); + this.undoing = false; + setCallbacks(); + // Reset search-base tracking whenever the editor is (re)initialized + this.searchBaseY = 0; + this.searchBaseHeight = 0; + this.searchAppliedOffset = 0; + this.searchBaseInitialized = false; + + KEYS_OVERLAY.openOnClick = true; + initGui(); + initializeKeyBindings(); + } + public void initGui() { + int endX = x + width, endY = y + height; + int xOffset = hasVerticalScrollbar() ? -8 : -2; + KEYS_OVERLAY.scale = 0.75f; + KEYS_OVERLAY.borderCol1 = KEYS_OVERLAY.borderCol2 = 0xFF3c3c3c; + int overlayWidth = 160; + KEYS_OVERLAY.initGui(x + (width - overlayWidth) / 2 + 5, y + height / 10, overlayWidth, + height - height / 5 - 10); + + KEYS_OVERLAY.viewButton.scale = 0.45f; + KEYS_OVERLAY.viewButton.initGui(endX + xOffset, endY - 26); + + // Initialize search bar (preserves state across initGui calls) + searchBar.initGui(x, y, width); + if (searchBar.isVisible()) { // If open + // Shift viewport down again using the bar's current height + searchBar.callback.resizeEditor(true, searchBar.getTotalHeight()); + if (!active) // Focus search if opening another script tab & bar is open + searchBar.focus(false); + } + + // Initialize Go To Line dialog + goToLineDialog.initGui(x, y, width); + } + + public void setCallbacks() { + searchBar.setCallback(new SearchReplaceBar.SearchCallback() { + @Override + public String getText() { + return GuiScriptTextArea1.this.text; + } + + public String getHighlightedWord() { + return GuiScriptTextArea1.this.highlightedWord; + } + + @Override + public int getSelectionStart() { + return GuiScriptTextArea1.this.selection.getStartSelection(); + } + + @Override + public int getSelectionEnd() { + return GuiScriptTextArea1.this.selection.getEndSelection(); + } + + @Override + public void setText(String newText) { + GuiScriptTextArea1.this.setText(newText); + } + + @Override + public void scrollToPosition(int position) { + // Find line containing position and scroll to it + if (container == null || container.lines == null) + return; + + // Calculate offset to account for search bar height + int searchBarOffset = searchBar.getTotalHeight(); + int effectiveHeight = GuiScriptTextArea1.this.height - searchBarOffset; + int visibleLines = effectiveHeight / container.lineHeight; + // Calculate how many lines the search bar covers + int linesHiddenBySRB = searchBarOffset > 0 ? (int) Math.ceil( + (double) searchBarOffset / container.lineHeight) : 0; + + for (int i = 0; i < container.lines.size(); i++) { + LineData ld = container.lines.get(i); + if (position >= ld.start && position < ld.end) { + int visible = Math.max(1, visibleLines); + int effectiveVisible = Math.max(1, visible - bottomPaddingLines); + int maxScroll = Math.max(0, getPaddedLineCount() - visible); + int targetLine = i; + + // If search bar is visible and would hide this line, scroll down so it's visible + // The target line should appear below the search bar, not under it + int currentScroll = scroll.getScrolledLine(); + int firstVisibleLine = currentScroll + linesHiddenBySRB; + + if (searchBarOffset > 0 && targetLine < firstVisibleLine) { + // Force scroll so target line appears just below the search bar + scroll.setTargetScroll(Math.max(0, targetLine - linesHiddenBySRB), maxScroll); + } else { + scroll.scrollToLine(targetLine, effectiveVisible, maxScroll); + } + break; + } + } + } + + @Override + public void setSelection(int start, int end) { + selection.setSelection(start, end); + selection.setCursorPositionDirect(end); + } + + @Override + public int getGutterWidth() { + return LINE_NUMBER_GUTTER_WIDTH; + } + + @Override + public void unfocusMainEditor() { + // Save position but unfocus + active = false; + } + + @Override + public void focusMainEditor() { + active = true; + searchBar.resetSelection(); + } + + @Override + public void onMatchesUpdated() { + // Called when matches change - could be used for UI updates + } + + // Robust resize: use base editor bounds and apply the requested offset + public void resizeEditor(boolean open, int barHeight) { + int desiredOffset = Math.max(0, barHeight); + // Initialize base values if not set + if (!searchBaseInitialized) { + searchBaseY = GuiScriptTextArea1.this.y; + searchBaseHeight = GuiScriptTextArea1.this.height; + searchAppliedOffset = 0; + searchBaseInitialized = true; + } + + int targetOffset = open ? desiredOffset : 0; + if (targetOffset == searchAppliedOffset) + return; // already in desired state + + // Compute new bounds from base values (idempotent) + int newY = searchBaseY + targetOffset; + int newHeight = Math.max(12, searchBaseHeight - targetOffset); + + GuiScriptTextArea1.this.y = newY; + GuiScriptTextArea1.this.height = newHeight; + searchAppliedOffset = targetOffset; + + if (container != null) + container.visibleLines = Math.max(GuiScriptTextArea1.this.height / container.lineHeight - 1, 1); + } + }); + // Initialize Go To Line dialog with callback + goToLineDialog.setCallback(new GoToLineDialog.GoToLineCallback() { + @Override + public int getLineCount() { + return container != null ? container.linesCount : 0; + } + + @Override + public int getColumnCount(int lineIndex) { + if (container == null || container.lines == null || lineIndex < 0 || lineIndex >= container.lines.size()) { + return 0; + } + LineData ld = container.lines.get(lineIndex); + return ld.end - ld.start; + } + + @Override + public void goToLineColumn(int line, int column) { + if (container == null || container.lines == null) + return; + + // Convert 1-indexed line to 0-indexed + int lineIdx = line - 1; + if (lineIdx < 0 || lineIdx >= container.lines.size()) + return; + + LineData ld = container.lines.get(lineIdx); + int lineLength = ld.end - ld.start; + + // Convert 1-indexed column to 0-indexed, clamp to line length + // lineLength - 1 because the line's end is the start of the next line + int col = Math.max(0, Math.min(column - 1, lineLength - 1)); + int position = ld.start + col; + + // Set cursor position + selection.reset(position); + + // Scroll to make the line visible + int visible = GuiScriptTextArea1.this.height / (container != null ? container.lineHeight : 12); + int effectiveVisible = Math.max(1, visible - bottomPaddingLines); + int maxScroll = Math.max(0, getPaddedLineCount() - visible); + scroll.scrollToLine(lineIdx, effectiveVisible, maxScroll); + } + + @Override + public void unfocusMainEditor() { + // Save position but unfocus + active = false; + } + + @Override + public void focusMainEditor() { + active = true; + selection.markActivity(); + } + + @Override + public void onDialogClose() { + active = true; + selection.markActivity(); + } + }); + + // Initialize Rename Refactor handler with callback + renameHandler.setCallback(new RenameRefactorHandler.RenameCallback() { + @Override + public String getText() { + return GuiScriptTextArea1.this.text; + } + + @Override + public void setText(String newText) { + GuiScriptTextArea1.this.setText(newText); + } + + @Override + public List getLines() { + return container != null ? container.lines : new ArrayList<>(); + } + + @Override + public int getCursorPosition() { + return selection.getCursorPosition(); + } + + public SelectionState getSelectionState() { + return selection; + } + + @Override + public void setCursorPosition(int pos) { + selection.reset(pos); + } + + @Override + public void unfocusMainEditor() { + active = false; + } + + @Override + public void focusMainEditor() { + active = true; + selection.markActivity(); + } + + @Override + public int getGutterWidth() { + return LINE_NUMBER_GUTTER_WIDTH; + } + + @Override + public int getLineHeight() { + return container != null ? container.lineHeight : 12; + } + + @Override + public int getScrolledLine() { + return scroll.getScrolledLine(); + } + + @Override + public double getFractionalOffset() { + return scroll.getFractionalOffset(); + } + + @Override + public void scrollToPosition(int pos) { + if (container == null || container.lines == null) + return; + for (int i = 0; i < container.lines.size(); i++) { + LineData ld = container.lines.get(i); + if (pos >= ld.start && pos < ld.end) { + int visible = Math.max(1, container.visibleLines); + int effectiveVisible = Math.max(1, visible - bottomPaddingLines); + int maxScroll = Math.max(0, getPaddedLineCount() - visible); + scroll.scrollToLine(i, effectiveVisible, maxScroll); + break; + } + } + } + + @Override + public ScriptTextContainer getContainer() { + return container; + } + + @Override + public void setTextWithoutUndo(String newText) { + // Set text without creating an undo entry (for live rename preview) + boolean wasUndoing = undoing; + undoing = true; + setText(newText); + undoing = wasUndoing; + } + + @Override + public void pushUndoState(String textState, int cursor) { + // Push a specific text state to the undo list + if (!undoing) { + undoList.add(new UndoData(textState, cursor)); + redoList.clear(); + } + } + + @Override + public int getViewportWidth() { + return width - LINE_NUMBER_GUTTER_WIDTH - 8; // Account for gutter and scrollbar + } + }); + } + + public boolean fullscreen() { + return GuiScriptInterface.isFullscreen; + } + // ==================== RENDERING ==================== + public void drawTextBox(int xMouse, int yMouse) { + if (!visible) + return; + clampSelectionBounds(); + + // Dynamically calculate gutter width based on line count digits + if (container != null && container.linesCount > 0) { + int maxLineNum = container.linesCount; + String maxLineStr = String.valueOf(maxLineNum); + int digitWidth = ClientProxy.Font.width(maxLineStr); + LINE_NUMBER_GUTTER_WIDTH = digitWidth + 10; // 10px total padding (5px left + 5px right) + } + // Draw outer border around entire area + int offset = fullscreen() ? 2 : 1; + drawRect(x - offset, y - offset - searchBar.getTotalHeight(), x + width + offset, y + height + offset, + 0xffa0a0a0); + + int searchHeight = searchBar.getTotalHeight(); + + + // Draw line number gutter background + drawRect(x, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff000000); + // Draw text viewport background (starts after gutter) + drawRect(x + LINE_NUMBER_GUTTER_WIDTH, y, x + width, y + height, 0xff000000); + // Draw separator line between gutter and text area + drawRect(x + LINE_NUMBER_GUTTER_WIDTH-1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff3c3f41); + + // Enable scissor test to clip drawing to the TEXT viewport rectangle (excludes gutter) + GL11.glEnable(GL11.GL_SCISSOR_TEST); + scissorViewport(); + + container.visibleLines = (height / container.lineHeight); + + int maxScroll = Math.max(0, getPaddedLineCount() - container.visibleLines); + + // Handle mouse wheel scroll + int wheelDelta = ((GuiNPCInterface) listener).mouseScroll = Mouse.getDWheel(); + if (listener instanceof GuiNPCInterface) { + ((GuiNPCInterface) listener).mouseScroll = wheelDelta; + boolean canScroll = !KEYS_OVERLAY.isVisible() || KEYS_OVERLAY.isVisible() && !KEYS_OVERLAY.aboveOverlay; + if (wheelDelta != 0 && canScroll) + scroll.applyWheelScroll(wheelDelta, maxScroll); + } + + // Handle scrollbar dragging (delegated to ScrollState) + if (scroll.isClickScrolling()) + scroll.handleClickScrolling(yMouse, x, y, height, container.visibleLines, getPaddedLineCount(), maxScroll); + + // Update scroll animation + scroll.initializeIfNeeded(scroll.getScrolledLine()); + scroll.update(maxScroll); + + // Handle click-dragging for selection + if (clicked) { + clicked = Mouse.isButtonDown(0); + int i = getSelectionPos(xMouse, yMouse); + if (i != selection.getCursorPosition()) { + if (doubleClicked || tripleClicked) { + selection.reset(selection.getCursorPosition()); + doubleClicked = false; + tripleClicked = false; + } + setCursor(i, true); + } + } else if (doubleClicked || tripleClicked) { + doubleClicked = false; + tripleClicked = false; + } + // y += searchHeight; + // height -= searchHeight; + // Calculate braces next to cursor to highlight + int startBracket = 0, endBracket = 0; + if (selection.getStartSelection() >= 0 && text != null && text.length() > 0 && + (selection.getEndSelection() - selection.getStartSelection() == 1 || !selection.hasSelection())) { + int[] span = BracketMatcher.findBracketSpanAt(text,selection.getStartSelection()); + if (span != null) { + startBracket = span[0]; + endBracket = span[1]; + } + } + + List list = new ArrayList<>(container.lines); + + // Build brace spans: {origDepth, open line, close line, adjustedDepth} + List braceSpans = BracketMatcher.computeBraceSpans(text, list); + // Always highlight unmatched braces (positions in text) + List unmatchedBraces = BracketMatcher.findUnmatchedBracePositions(text); + + // Determine which exact brace span (openLine/closeLine) to highlight indent guides for + int highlightedOpenLine = -1; + int highlightedCloseLine = -1; + if (startBracket != endBracket && startBracket >= 0) { + int bracketLineIdx = -1; + // Only consider curly braces for highlighting the indent guides + boolean isCurlyBracket = false; + char bc = text.charAt(startBracket); + if (text != null && startBracket >= 0 && startBracket < text.length()) { + if (bc == '{' || bc == '}') isCurlyBracket = true; + } + for (int li = 0; li < list.size(); li++) { + LineData ld = list.get(li); + if (startBracket >= ld.start && startBracket < ld.end) { + bracketLineIdx = li; + break; + } + } + if (bracketLineIdx >= 0 && isCurlyBracket) { + // Prefer a span that directly matches the bracket character's line: + // - if the bracket is an opening '{', prefer spans where openLine == bracketLineIdx + // - if the bracket is a closing '}', prefer spans where closeLine == bracketLineIdx + // If no exact match is found, fall back to the smallest enclosing span (innermost). + int bestSize = Integer.MAX_VALUE; + boolean foundExact = false; + char bracketChar = bc; // from earlier + for (int[] span : braceSpans) { + int openLine = span[1]; + int closeLine = span[2]; + if (bracketLineIdx >= openLine && bracketLineIdx <= closeLine) { + int size = closeLine - openLine; + boolean exactMatch = (bracketChar == '{' && openLine == bracketLineIdx) || (bracketChar == '}' && closeLine == bracketLineIdx); + if (exactMatch) { + // Prefer exact matches immediately (still choose smallest exact span) + if (!foundExact || size < bestSize) { + foundExact = true; + bestSize = size; + highlightedOpenLine = openLine; + highlightedCloseLine = closeLine; + } + } else if (!foundExact) { + // keep the smallest enclosing span as a fallback + if (size < bestSize) { + bestSize = size; + highlightedOpenLine = openLine; + highlightedCloseLine = closeLine; + } + } + } + } + } + } + + highlightedWord = null; + if (selection.hasSelection()) { + Matcher m = container.regexWord.matcher(text); + while (m.find()) { + if (m.start() == selection.getStartSelection() && m.end() == selection.getEndSelection()) { + highlightedWord = text.substring(selection.getStartSelection(), selection.getEndSelection()); + } + } + } + + // Apply fractional GL translate for sub-pixel smooth scrolling + double fracOffset = scroll.getFractionalOffset(); + float fracPixels = (float) (fracOffset * container.lineHeight); + GL11.glPushMatrix(); + GL11.glTranslatef(0.0f, -fracPixels, 0.0f); + + + // Expand render range by one line above/below so partially-visible lines are drawn + int renderStart = Math.max(0, scroll.getScrolledLine() - 1); + // Compute the last line index to render, including the last partially-visible line if any. + // Adds the fractional scroll offset (fracOffset * lineHeight) to ensure the bottom line is drawn + // when only part of it is visible in the viewport. + int renderEnd = (int) Math.min(list.size() - 1, + scroll.getScrolledLine() + container.visibleLines + fracPixels + 1); + + // Strings start drawing vertically this much into the line. + int stringYOffset = 2; + + // Render LINE GUTTER numbers + for (int i = renderStart; i <= renderEnd; i++) { + int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight + stringYOffset; + String lineNum = String.valueOf(i + 1); + int lineNumWidth = ClientProxy.Font.width(lineNum); + int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5; // right-align with 5px padding + int lineNumY = posY + 1; + // Highlight current line number + int lineNumColor = 0xFF606366; + if (active && isEnabled()) { + for (int li = 0; li < list.size(); li++) { + LineData ld = list.get(li); + if (selection.getCursorPosition() >= ld.start && selection.getCursorPosition() < ld.end || (li == list.size() - 1 && selection.getCursorPosition() == text.length())) { + if (li == i) { + lineNumColor = 0xFFb9c7d6; + break; + } + } + } + } + ClientProxy.Font.drawString(lineNum, lineNumX, lineNumY, lineNumColor); + } + + // Render Viewport + for (int i = renderStart; i <= renderEnd; i++) { + LineData data = list.get(i); + ScriptLine scriptLine = container.getDocument().getLineAt(i); + String line = data.text; + int w = line.length(); + // Use integer Y relative to scrolledLine; fractional offset applied via GL translate + int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight; + if (i >= renderStart && i <= renderEnd) { + //Highlight braces the cursor position is on + if (startBracket != endBracket) { + if (startBracket >= data.start && startBracket < data.end) { + int s = ClientProxy.Font.width(line.substring(0, startBracket - data.start)); + int e = ClientProxy.Font.width(line.substring(0, startBracket - data.start + 1)) + 1; + drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00); + } + if (endBracket >= data.start && endBracket < data.end) { + int s = ClientProxy.Font.width(line.substring(0, endBracket - data.start)); + int e = ClientProxy.Font.width(line.substring(0, endBracket - data.start + 1)) + 1; + drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00); + } + } + + // Highlight unmatched braces in this line (always red) + if (unmatchedBraces != null && !unmatchedBraces.isEmpty()) { + for (int ubPos : unmatchedBraces) { + if (ubPos >= data.start && ubPos < data.end) { + int rel = ubPos - data.start; + int s = ClientProxy.Font.width(line.substring(0, rel)); + int e = ClientProxy.Font.width(line.substring(0, rel + 1)) + 1; + drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0xffcc0000); + } + } + } + //Highlight words + if (highlightedWord != null) { + Matcher m = container.regexWord.matcher(line); + while (m.find()) { + if (line.substring(m.start(), m.end()).equals(highlightedWord)) { + int s = ClientProxy.Font.width(line.substring(0, m.start())); + int e = ClientProxy.Font.width(line.substring(0, m.end())) + 1; + drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x99004c00); + } + } + } + + // Highlight search matches + if (searchBar.isVisible()) { + List searchMatches = searchBar.getMatches(); + int currentMatchIdx = searchBar.getCurrentMatchIndex(); + for (int mi = 0; mi < searchMatches.size(); mi++) { + int[] match = searchMatches.get(mi); + // Check if match overlaps with this line + if (match[1] > data.start && match[0] < data.end) { + int matchStart = Math.max(match[0] - data.start, 0); + int matchEnd = Math.min(match[1] - data.start, line.length()); + if (matchStart < matchEnd) { + int s = ClientProxy.Font.width(line.substring(0, matchStart)); + int e = ClientProxy.Font.width(line.substring(0, matchEnd)) + 1; + boolean isExcluded = searchBar.isMatchExcluded(mi); + // Current match gets brighter highlight, others get dimmer + int highlightColor = (mi == currentMatchIdx) ? 0xBB4488ff : 0x662266aa; + if (isExcluded) { + highlightColor = 0x33666666; // Dimmer for excluded matches + } + int highlightX = x + LINE_NUMBER_GUTTER_WIDTH + 1 + s; + int highlightEndX = x + LINE_NUMBER_GUTTER_WIDTH + 1 + e; + drawRect(highlightX, posY, highlightEndX, posY + container.lineHeight, highlightColor); + + // Draw strikethrough line for excluded matches + if (isExcluded) { + int strikeY = posY + container.lineHeight / 2; + drawRect(highlightX, strikeY, highlightEndX, strikeY + 1, 0xFFaa4444); + } + } + } + } + } + + // Highlight rename refactor occurrences + if (renameHandler.isActive()) { + List renameOccurrences = renameHandler.getOccurrences(); + for (int[] occ : renameOccurrences) { + // Check if occurrence overlaps with this line + if (occ[1] >= data.start && occ[0] <= data.end) { + int occStart = Math.max(occ[0] - data.start, 0); + int occEnd = Math.min(occ[1] - data.start, line.length()); + + // Handle empty word case - draw 1 pixel wide box + boolean isEmpty = (occ[0] == occ[1]); + + if (occStart <= occEnd) { // Changed from < to <= to handle empty case + int s = ClientProxy.Font.width(line.substring(0, occStart)); + int e = isEmpty ? s + 2 : ClientProxy.Font.width( + line.substring(0, occEnd)) + 1; // 2px wide for empty + int occX = x + LINE_NUMBER_GUTTER_WIDTH + s; + int occEndX = x + LINE_NUMBER_GUTTER_WIDTH + 2 + e; + boolean isPrimary = renameHandler.isPrimaryOccurrence(occ[0]); + + // Draw background highlight + int bgColor = isPrimary ? 0x55335577 : 0x33224466; + drawRect(occX, posY, occEndX, posY + container.lineHeight, bgColor); + + // Draw white border for primary occurrence (IntelliJ-like) + if (isPrimary) { + int borderColor = 0xDDFFFFFF; + //RED BG + drawRect(occX, posY, occEndX, posY + container.lineHeight, 0x33ff0000); + + // Top border + drawRect(occX, posY, occEndX, posY + 1, borderColor); + // Bottom border + drawRect(occX, posY + container.lineHeight - 1, occEndX, + posY + container.lineHeight, borderColor); + // Left border + drawRect(occX, posY, occX + 1, posY + container.lineHeight, borderColor); + // Right border + drawRect(occEndX - 1, posY, occEndX, posY + container.lineHeight, borderColor); + + // Draw cursor inside the primary occurrence + if (renameHandler.shouldShowCursor()) { + int cursorInWord = renameHandler.getCursorInWord(); + String currentWord = renameHandler.getCurrentWord(); + if (currentWord != null && cursorInWord >= 0 && cursorInWord <= currentWord.length()) { + String beforeCursor = currentWord.substring(0, + Math.min(cursorInWord, currentWord.length())); + int cursorX = occX + ClientProxy.Font.width(beforeCursor); + // drawRect(cursorX, posY + 1, cursorX + 1, posY + container.lineHeight - 1, + // 0xFFFFFFFF); + } + } + } + } + } + } + } + + // Highlight the current line (light gray) under any selection + if (active && isEnabled() && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { + drawRect(x , posY, x + width - 1, posY + container.lineHeight, 0x22e0e0e0); + } + // Highlight selection + if (selection.hasSelection() && selection.getEndSelection() > data.start && selection.getStartSelection() <= data.end) { + if (selection.getStartSelection() < data.end) { + int s = ClientProxy.Font.width( + line.substring(0, Math.max(selection.getStartSelection() - data.start, 0))); + int e = ClientProxy.Font.width( + line.substring(0, Math.min(selection.getEndSelection() - data.start, w))) + 1; + drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x992172ff); + } + } + + // Draw indent guides once per visible block based on brace spans + if (i == Math.max(0, scroll.getScrolledLine()) && !braceSpans.isEmpty()) { + int visStart = Math.max(0, scroll.getScrolledLine()); + int visEnd = Math.min(list.size() - 1, visStart + container.visibleLines - 0); + for (int[] span : braceSpans) { + int originalDepth = span[0]; + int openLine = span[1]; + int closeLine = span[2]; + int depth = span.length > 3 ? span[3] : originalDepth; + // Skip top-level (depth 1) using the original depth to avoid hiding nested guides when adjusted + if (originalDepth <= 1) + continue; + int startLine = openLine + 1; // start under the opening brace + int endLine = closeLine - 1; // stop before the closing brace + if (startLine > endLine) + continue; + if (endLine < visStart || startLine > visEnd) + continue; + + int drawStart = Math.max(startLine, visStart); + int drawEnd = Math.min(endLine, visEnd); + // Compute horizontal position: 4 spaces per indent level, minus a tiny left offset + int safeDepth = Math.max(1, depth); + int spaces = (safeDepth - 1) * 4; + StringBuilder sb = new StringBuilder(); + for (int k = 0; k < spaces; k++) + sb.append(' '); + int px = ClientProxy.Font.width(sb.toString()); + int gx = x + LINE_NUMBER_GUTTER_WIDTH + 4 + px - 2; // shift left ~2px for the IntelliJ feel + + boolean highlighted = (openLine == highlightedOpenLine && closeLine == highlightedCloseLine); + int guideColor = highlighted ? 0x9933cc00 : 0x33FFFFFF; + + int topY = y + (drawStart - scroll.getScrolledLine()) * container.lineHeight; + int bottomY = y + (endLine - scroll.getScrolledLine() + 1) * container.lineHeight; + if(highlighted) + bottomY-=2; + drawRect(gx, topY, gx + 1, bottomY, guideColor); + } + } + int yPos = posY + stringYOffset; + + // data.drawString(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0); + scriptLine.drawString(x+LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0); + + // Draw cursor: pause blinking while user is active recently + boolean recentInput = selection.hadRecentInput(); + if (active && isEnabled() && (recentInput || (cursorCounter / 10) % 2 == 0) && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { + int posX = x + LINE_NUMBER_GUTTER_WIDTH + ClientProxy.Font.width( + line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length()))); + drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, 0xffffffff); + } + } + } + GL11.glPopMatrix(); + GL11.glDisable(GL11.GL_SCISSOR_TEST); + + + if (hasVerticalScrollbar()) { + Minecraft.getMinecraft().renderEngine.bindTexture(GuiCustomScroll.resource); + int effLines = Math.max(1, getPaddedLineCount()); + int sbSize = Math.max((int) (1f * (container.visibleLines) / effLines * height), 2); + + int posX = x + width - 6; + double linesCount = (double) effLines; + int posY = (int) (y + 1f * scroll.getScrollPos() / linesCount * (height - 4)) + 1; + + drawRect(posX, posY, posX + 5, posY + sbSize + 2, 0xFFe0e0e0); + } + + // Draw search/replace bar (overlays viewport) + searchBar.draw(xMouse, yMouse); + + // Draw go to line dialog (overlays everything) + goToLineDialog.draw(xMouse, yMouse); + KEYS_OVERLAY.draw(xMouse, yMouse, wheelDelta); + } + + private void scissorViewport() { + Minecraft mc = Minecraft.getMinecraft(); + ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + int scaleFactor = sr.getScaleFactor(); + int scissorX = (this.x) * scaleFactor; + int scissorY = (sr.getScaledHeight() - (this.y + this.height)) * scaleFactor; + int scissorW = (this.width) * scaleFactor; + int scissorH = this.height * scaleFactor; + GL11.glScissor(scissorX, scissorY, scissorW, scissorH); + } + // ==================== SELECTION & CURSOR POSITION ==================== + + // Get cursor position from mouse coordinates + private int getSelectionPos(int xMouse, int yMouse) { + xMouse -= (this.x + LINE_NUMBER_GUTTER_WIDTH + 1); + yMouse -= this.y + 1; + // Adjust yMouse to account for fractional GL translation (negative offset applied in rendering). + // Use a double here (no integer rounding) so clicks that land on partially + // visible lines (fractional positions) correctly hit that line. + double fracPixels = scroll.getFractionalOffset() * container.lineHeight; + double yMouseD = yMouse + fracPixels; + + ArrayList list = new ArrayList(this.container.lines); + + for (int i = 0; i < list.size(); ++i) { + LineData data = (LineData) list.get(i); + //+1 to account for the fractional line + if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines +1) { + double yPos = (i - scroll.getScrolledLine()) * this.container.lineHeight; + if (yMouseD >= yPos && yMouseD < yPos + this.container.lineHeight) { + int lineWidth = 0; + char[] chars = data.text.toCharArray(); + + for (int j = 1; j <= chars.length; ++j) { + int w = ClientProxy.Font.width(data.text.substring(0, j)); + if (xMouse < lineWidth + (w - lineWidth) / 2) { + return data.start + j - 1; + } + + lineWidth = w; + } + + // Place cursor after the last visible character of the line. + // `data.end - 1` previously pointed at the newline for non-last lines + // which made clicks land on the newline rather than after the text. + // Use data.start + chars.length to return the position directly + // after the line's characters, clamped to the total text length. + int posAfterChars = data.start + chars.length; + return Math.min(posAfterChars, text.length()); + } + } + } + + return this.container.text.length(); + } + + // Find which line the cursor is on (0-indexed) + private int getCursorLineIndex() { + return selection.getCursorLineIndex(container.lines, text != null ? text.length() : 0); + } + + + // Scroll viewport to keep cursor visible (minimal adjustment, like IntelliJ) + // Only scrolls if cursor is outside the visible area + private void scrollToCursor() { + if (container == null || container.lines == null || container.lines.isEmpty()) return; + + int lineIdx = getCursorLineIndex(); + int visible = Math.max(1, container.visibleLines); + int effectiveVisible = Math.max(1, visible - bottomPaddingLines); + int maxScroll = Math.max(0, getPaddedLineCount() - visible); + + scroll.scrollToLine(lineIdx, effectiveVisible, maxScroll); + } + + // ==================== KEY BINDINGS INITIALIZATION ==================== + + /** + * Initialize key bindings for editor shortcuts using ScriptEditorKeys. + * Centralized active/enabled checking ensures shortcuts only fire when appropriate. + */ + private void initializeKeyBindings() { + // Helper: execute action only if text area is active and enabled + Supplier isActive = () -> active && isEnabled() && !KEYS_OVERLAY.isVisible(); + Supplier openBoxes = () -> !KEYS_OVERLAY.isVisible(); + + // CUT: Copy selection to clipboard and delete it. If no selection, cut the current sentence. + KEYS.CUT.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + if (selection.hasSelection()) { + NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); + String s = getSelectionBeforeText(); + setText(s + getSelectionAfterText()); + selection.reset(s.length()); + scrollToCursor(); + return; + } + + // No selection: cut the current sentence (heuristic based on .!? or newline) + if (text == null || text.isEmpty()) + return; + int cursor = selection.getCursorPosition(); + int start = cursor; + int pos = cursor - 1; + while (pos >= 0) { + char ch = text.charAt(pos); + if (ch == '.' || ch == '!' || ch == '?' || ch == '\n') { + start = pos + 1; + break; + } + pos--; + } + while (start < cursor && Character.isWhitespace(text.charAt(start))) start++; + + int end = cursor; + pos = cursor; + while (pos < text.length()) { + char ch = text.charAt(pos); + if (ch == '.' || ch == '!' || ch == '?' || ch == '\n') { + end = pos + 1; + break; + } + pos++; + } + if (end == cursor) end = text.length(); + while (end > start && Character.isWhitespace(text.charAt(end - 1))) end--; + + if (start >= end) { + // fallback: cut whole current line + int ls = text.lastIndexOf('\n', Math.max(0, cursor - 1)); + start = ls == -1 ? 0 : ls + 1; + int le = text.indexOf('\n', cursor); + end = le == -1 ? text.length() : le; + } + + if (start < end) { + String cut = text.substring(start, end); + NoppesStringUtils.setClipboardContents(cut); + setText(text.substring(0, start) + text.substring(end)); + selection.reset(start); + scrollToCursor(); + } + }); + + // COPY: Copy selection to clipboard + KEYS.COPY.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + if (selection.hasSelection()) + NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); + }); + + // PASTE: Insert clipboard contents at caret + KEYS.PASTE.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + addText(NoppesStringUtils.getClipboardContents()); + scrollToCursor(); + }); + + // UNDO: Restore last edit from undo list + // Works in search bar. + KEYS.UNDO.setTask(e -> { + if ((!e.isPress() && !e.isHold()) || !openBoxes.get()) + return; + + if (searchBar.hasFocus()) { + searchBar.undo(); + } else { + if (undoList.isEmpty()) + return; + undoing = true; + redoList.add(new UndoData(this.text, selection.getCursorPosition())); + UndoData data = undoList.remove(undoList.size() - 1); + setText(data.text); + selection.reset(data.cursorPosition); + undoing = false; + scrollToCursor(); + searchBar.updateMatches(); + if (!active) + active = true; + } + }); + + // REDO: Restore last undone edit from redo list + // Works in search bar. + KEYS.REDO.setTask(e -> { + if ((!e.isPress() && !e.isHold()) || !openBoxes.get()) + return; + + if (searchBar.hasFocus()) { + searchBar.redo(); + } else { + if (redoList.isEmpty()) + return; + undoing = true; + undoList.add(new UndoData(this.text, selection.getCursorPosition())); + UndoData data = redoList.remove(redoList.size() - 1); + setText(data.text); + selection.reset(data.cursorPosition); + undoing = false; + scrollToCursor(); + searchBar.updateMatches(); + if (!active) + active = true; + } + }); + + // FORMAT: Format/indent code + KEYS.FORMAT.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + formatText(); + }); + + // TOGGLE_COMMENT: Toggle comment for selection or current line + KEYS.TOGGLE_COMMENT.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + if (selection.hasSelection()) + toggleCommentSelection(); + else + toggleCommentLineAtCursor(); + }); + + // DUPLICATE: Duplicate selection or current line + KEYS.DUPLICATE.setTask(e -> { + if (!e.isPress() || !isActive.get()) + return; + + if (selection.hasSelection()) { + // Multi-line selection duplication + LineData firstLine = null, lastLine = null; + for (LineData line : container.lines) { + if (line.end > selection.getStartSelection() && line.start < selection.getEndSelection()) { + if (firstLine == null) + firstLine = line; + lastLine = line; + } + } + if (firstLine != null && lastLine != null) { + String selectedText = text.substring(firstLine.start, lastLine.end); + int savedStart = selection.getStartSelection(); + int savedEnd = selection.getEndSelection(); + int insertAt = lastLine.end; + setText(text.substring(0, insertAt) + selectedText + text.substring(insertAt)); + selection.setStartSelection(savedStart); + selection.setEndSelection(savedEnd); + selection.setCursorPositionDirect(savedEnd); + } + } else { + // Duplicate current line + for (LineData line : container.lines) { + if (selection.getCursorPosition() >= line.start && selection.getCursorPosition() <= line.end) { + int safeStart = Math.max(0, Math.min(line.start, text.length())); + int safeEnd = Math.max(safeStart, Math.min(line.end, text.length())); + String lineText = text.substring(safeStart, safeEnd); + String insertText = lineText.endsWith("\n") ? lineText : "\n" + lineText; + int insertionPoint = Math.min(line.end, text.length()); + setText(text.substring(0, insertionPoint) + insertText + text.substring(insertionPoint)); + int newCursor = insertionPoint + insertText.length() - (insertText.endsWith("\n") ? 1 : 0); + selection.reset(Math.max(0, Math.min(newCursor, this.text.length()))); + break; + } + } + } + }); + + + // Check if can open just for SearchReplaceBar and GoToLine + + // SEARCH: Open search bar (Ctrl+R) + // Works in search bar. + KEYS.SEARCH.setTask(e -> { + if (!e.isPress() || !openBoxes.get()) + return; + + unfocusAll(); + searchBar.openSearch(); + }); + + // SEARCH_REPLACE: Open search+replace bar (Ctrl+Shift+R) + // Works in search bar. + KEYS.SEARCH_REPLACE.setTask(e -> { + if (!e.isPress() || !openBoxes.get()) + return; + + unfocusAll(); + searchBar.openSearchReplace(); + }); + + // GO_TO_LINE: Open go to line dialog (Ctrl+G) + // Works in search bar. + KEYS.GO_TO_LINE.setTask(e -> { + if (!e.isPress() || !openBoxes.get()) + return; + + unfocusAll(); + goToLineDialog.toggle(); + }); + + // RENAME: Start rename refactoring (Shift+F6) + KEYS.RENAME.setTask(e -> { + if (!e.isPress() || !openBoxes.get()) + return; + + + if (!renameHandler.isActive()) { + unfocusAll(); + active = true; + renameHandler.startRename(); + } + }); + } + + public void unfocusAll() { + if (searchBar.hasFocus()) searchBar.unfocus(); + if (goToLineDialog.hasFocus()) goToLineDialog.unfocus(); + if (renameHandler.isActive()) + renameHandler.cancel(); + } + // ==================== KEYBOARD INPUT HANDLING ==================== + + /** + * Handles keyboard input for the text area, delegating to specialized handlers + * for different types of input: navigation, deletion, shortcuts, and character input. + */ + @Override + public boolean textboxKeyTyped(char c, int i) { + if (KEYS_OVERLAY.keyTyped(c, i)) + return true; + + // Handle rename refactor input first if active + if (renameHandler.isActive() && renameHandler.keyTyped(c, i)) + return true; + + // Handle Go To Line dialog input first if it has focus + if (goToLineDialog.isVisible() &&goToLineDialog.keyTyped(c, i)) + return true; + + // Handle search bar input first if it has focus + if (searchBar.isVisible() && searchBar.keyTyped(c, i)) + return true; + + if (!active) + return false; + + if (this.isKeyComboCtrlA(i)) { + selection.selectAll(text.length()); + return true; + } + + if (!isEnabled()) return false; + + if (handleNavigationKeys(i)) return true; + if (handleInsertionKeys(i)) return true; + if (handleDeletionKeys(i)) return true; + if (handleCharacterInput(c)) return true; + + return true; + } + + /** + * Handles cursor navigation keys (arrows) with support for word-jumping (Ctrl) + * and selection (Shift). Updates cursor position and scrolls viewport if needed. + */ + private boolean handleNavigationKeys(int i) { + // LEFT ARROW: move cursor left; with Ctrl -> jump by word + if (i == Keyboard.KEY_LEFT) { + int j = 1; // default: move one character + if (isCtrlKeyDown()) { + // When Ctrl is down, compute distance to previous word boundary. + // We match words in the text slice before the cursor and pick + // the last match start as the new boundary. + Matcher m = container.regexWord.matcher(text.substring(0, selection.getCursorPosition())); + while (m.find()) { + if (m.start() == m.end()) + continue; // skip empty matches + // j becomes the number of chars to move left to reach word start + j = selection.getCursorPosition() - m.start(); + } + } + int newPos = Math.max(selection.getCursorPosition() - j, 0); + // If Shift is held, extend selection; otherwise place caret. + setCursor(newPos, GuiScreen.isShiftKeyDown()); + return true; + } + + // RIGHT ARROW: move cursor right; with Ctrl -> jump to next word start + if (i == Keyboard.KEY_RIGHT) { + int j = 1; // default: move one character + if (isCtrlKeyDown()) { + String after = text.substring(selection.getCursorPosition()); + Matcher m = container.regexWord.matcher(after); + if (m.find()) { + if (m.start() == 0) { + // If the first match starts at 0 (cursor at word start), + // try to find the next match so we advance past the current word. + if (m.find()) + j = m.start(); + else + j = Math.max(1, after.length()); + } else { + j = m.start(); + } + } else { + // No word match found after cursor -> jump to end + j = Math.max(1, after.length()); + } + } + int newPos = Math.min(selection.getCursorPosition() + j, text.length()); + setCursor(newPos, GuiScreen.isShiftKeyDown()); + return true; + } + + // UP/DOWN: logical cursor movement across lines while preserving + // column where possible. After moving, ensure the caret remains visible + // by adjusting the scroll if necessary. + if (i == Keyboard.KEY_UP) { + setCursor(cursorUp(), GuiScreen.isShiftKeyDown()); + scrollToCursor(); + return true; + } + if (i == Keyboard.KEY_DOWN) { + setCursor(cursorDown(), GuiScreen.isShiftKeyDown()); + scrollToCursor(); + return true; + } + + return false; // not a navigation key + } + + /** + * Handles insertion-related keys such as Tab indentation and Enter behavior. + */ + private boolean handleInsertionKeys(int i) { + // TAB: indent or unindent depending on Shift + if (i == Keyboard.KEY_TAB) { + boolean shift = isShiftKeyDown(); + if (shift) { + handleShiftTab(); + } else { + handleTab(); + } + scrollToCursor(); + return true; + } + + // RETURN/ENTER: special handling when preceding char is an opening brace '{' + if (i == Keyboard.KEY_RETURN) { + int cursorPos = selection.getCursorPosition(); + int prevNonWs = cursorPos - 1; + while (prevNonWs >= 0 && prevNonWs < (text != null ? text.length() : 0) && Character.isWhitespace( + text.charAt(prevNonWs))) { + prevNonWs--; + } + + if (prevNonWs >= 0 && cursorPos <= (text != null ? text.length() : 0) && text.charAt(prevNonWs) == '{') { + String indent = ""; + for (LineData ld : this.container.lines) { + if (prevNonWs >= ld.start && prevNonWs < ld.end) { + indent = ld.text.substring(0, IndentHelper.getLineIndent(ld.text)); + break; + } + } + if (indent == null) + indent = ""; + String childIndent = indent + " "; + String before = getSelectionBeforeText(); + String after = getSelectionAfterText(); + + int firstNewline = after.indexOf('\n'); + String leadingSegment = firstNewline == -1 ? after : after.substring(0, firstNewline); + if (leadingSegment.trim().length() > 0) { + addText("\n" + childIndent); + scrollToCursor(); + return true; + } + + boolean hasMatchingCloseSameIndent = false; + try { + int openLineIdx = -1; + int bracePos = prevNonWs; + for (int li = 0; li < this.container.lines.size(); li++) { + LineData ld = this.container.lines.get(li); + if (bracePos >= ld.start && bracePos < ld.end) { + openLineIdx = li; + break; + } + } + + if (openLineIdx >= 0) { + List spans = BracketMatcher.computeBraceSpans(text, this.container.lines); + for (int[] span : spans) { + int spanOpen = span[1]; + int spanClose = span[2]; + if (spanOpen == openLineIdx) { + int closeIndent = IndentHelper.getLineIndent(this.container.lines.get(spanClose).text); + if (closeIndent == indent.length()) { + hasMatchingCloseSameIndent = true; + } + break; + } + } + } + } catch (Exception ex) { + hasMatchingCloseSameIndent = false; + } + + if (hasMatchingCloseSameIndent) { + addText("\n" + childIndent); + scrollToCursor(); + } else { + String insert = "\n" + childIndent + "\n" + indent + "}"; + setText(before + insert + after); + int newCursor = before.length() + 1 + childIndent.length(); + selection.reset(newCursor); + scrollToCursor(); + } + } else { + addText(Character.toString('\n') + getAutoIndentForEnter()); + scrollToCursor(); + } + return true; + } + + return false; + } + + /** + * Handles deletion keys: Delete, Backspace, and Ctrl+Backspace. + * Includes smart backspace behavior for indentation-aware line merging, + * auto-pair deletion for brackets/quotes, and word-level deletion. + */ + private boolean handleDeletionKeys(int i) { + // DELETE key: remove the character under the cursor if no selection, + // otherwise remove the selected region's tail. + if (i == Keyboard.KEY_DELETE) { + String s = getSelectionAfterText(); + if (!s.isEmpty() && !selection.hasSelection()) + // remove single character after caret when nothing is selected + s = s.substring(1); + setText(getSelectionBeforeText() + s); + // Keep caret at same start selection + selection.reset(selection.getStartSelection()); + return true; + } + + // CTRL+BACKSPACE: delete to previous word or whitespace boundary. + if (isKeyComboCtrlBackspace(i)) { + String s = getSelectionBeforeText(); + if (selection.getStartSelection() > 0 && !selection.hasSelection()) { + int nearestCondition = selection.getCursorPosition(); + int g; + // If the char left of caret is whitespace, find the first non-space to the left; + // otherwise find first whitespace/newline to the left (word boundary). + boolean cursorInWhitespace = Character.isWhitespace(s.charAt(selection.getCursorPosition() - 1)); + if (cursorInWhitespace) { + // Scan left until non-whitespace (start of previous word) + for (g = selection.getCursorPosition() - 1; g >= 0; g--) { + char currentChar = s.charAt(g); + if (!Character.isWhitespace(currentChar)) { + nearestCondition = g; + break; + } + if (g == 0) { + nearestCondition = 0; + } + } + } else { + // Scan left until whitespace/newline is found (word boundary) + for (g = selection.getCursorPosition() - 1; g >= 0; g--) { + char currentChar = s.charAt(g); + if (Character.isWhitespace(currentChar) || currentChar == '\n') { + nearestCondition = g; + break; + } + if (g == 0) { + nearestCondition = 0; + } + } + } + + // Trim the prefix up to the discovered boundary + s = s.substring(0, nearestCondition); + // Adjust selection start to match removed characters + selection.setStartSelection( + selection.getStartSelection() - (selection.getCursorPosition() - nearestCondition)); + } + setText(s + getSelectionAfterText()); + selection.reset(selection.getStartSelection()); + return true; + } + + // BACKSPACE: complex handling with a few cases: + // 1) If a selection exists, delete it + // 2) If at start, nothing to do + // 3) Smart indent-aware merge with previous line when caret is at/near expected indent + // 4) Auto-pair deletion (remove both opening and closing chars when deleting an opener) + // 5) Fallback: delete a single char to the left + if (i == Keyboard.KEY_BACK) { + // 1) selection deletion + if (selection.hasSelection()) { + String s = getSelectionBeforeText(); + setText(s + getSelectionAfterText()); + selection.reset(selection.getStartSelection()); + scrollToCursor(); + return true; + } + + // 2) nothing to delete + if (selection.getStartSelection() <= 0) { + return true; + } + + // If the current line is whitespace-only, delete the whole line + // (including the trailing newline if present). This makes Backspace + // intuitive on blank/indented lines outside any recognized scope. + LineData currCheck = selection.findCurrentLine(container.lines); + if (currCheck != null && currCheck.text.trim().length() == 0) { + int removeEnd = text.indexOf('\n', currCheck.start - 1); + if (removeEnd == -1) { + removeEnd = text.length(); + } else { + removeEnd = removeEnd + 1; // include the newline + } + String before = text.substring(0, ValueUtil.clamp(currCheck.start - 1, 0, text.length())); + String after = removeEnd <= text.length() ? text.substring(removeEnd) : ""; + setText(before + after); + int newCursor = Math.max(0, currCheck.start - 1); + selection.reset(newCursor); + scrollToCursor(); + return true; + } + + // 3) indent-aware merge: find current line and compute expected indent + LineData curr = selection.findCurrentLine(container.lines); + if (curr != null && curr.start > 0) { + int col = selection.getCursorPosition() - curr.start; + int actualIndent = IndentHelper.getLineIndent(curr.text); + int expectedIndent = IndentHelper.getExpectedIndent(curr, container.lines); + + // Trigger smart merge only when caret is at or before the expected indent. + if (col <= expectedIndent) { + boolean lineHasContent = curr.text.trim().length() > 0; + int newlinePos = curr.start - 1; // index of newline before this line + + if (!lineHasContent) { + // Empty or whitespace-only line: remove it including its trailing newline + int removeEnd = text.indexOf('\n', curr.start); + if (removeEnd == -1) { + removeEnd = text.length(); + } else { + removeEnd = removeEnd + 1; // include the newline in removal + } + String before = text.substring(0, curr.start); + String after = removeEnd <= text.length() ? text.substring(removeEnd) : ""; + setText(before + after); + // Place caret at end of previous line + int newCursor = Math.max(0, curr.start - 1); + selection.reset(newCursor); + scrollToCursor(); + return true; + } else { + // Merge current line content with the previous line preserving spacing + int contentStart = curr.start + actualIndent; + String before = newlinePos >= 0 ? text.substring(0, newlinePos) : ""; + String content = contentStart <= text.length() ? text.substring(contentStart) : ""; + + // Decide whether a space is needed between concatenated fragments. + String spacer = ""; + if (before.length() > 0 && content.length() > 0) { + char lastChar = before.charAt(before.length() - 1); + char firstChar = content.charAt(0); + // Avoid adding space when punctuation/brackets are adjacent + if (!Character.isWhitespace(lastChar) && + lastChar != '{' && lastChar != '(' && lastChar != '[' && + firstChar != '}' && firstChar != ')' && firstChar != ']' && + firstChar != ';' && firstChar != ',' && firstChar != '.' && + firstChar != '\n') { + spacer = " "; + } + } + + setText(before + spacer + content); + int newCursor = before.length() + spacer.length(); + selection.reset(newCursor); + scrollToCursor(); + return true; + } + } + } + + // 4) Auto-pair deletion: when deleting an opener and a matching closer follows, + // remove both so the pair is cleaned up in one backspace. + if (selection.getStartSelection() > 0 && selection.getStartSelection() < text.length()) { + char prev = text.charAt(selection.getStartSelection() - 1); + char nextc = text.charAt(selection.getStartSelection()); + if ((prev == '(' && nextc == ')') || + (prev == '[' && nextc == ']') || + (prev == '{' && nextc == '}') || + (prev == '\'' && nextc == '\'') || + (prev == '"' && nextc == '"')) { + String before = text.substring(0, selection.getStartSelection() - 1); + String after = selection.getStartSelection() + 1 < text.length() ? text.substring( + selection.getStartSelection() + 1) : ""; + setText(before + after); + selection.setStartSelection(selection.getStartSelection() - 1); + selection.reset(selection.getStartSelection()); + scrollToCursor(); + return true; + } + } + + // 5) Normal single-character backspace + String s = getSelectionBeforeText(); + s = s.substring(0, s.length() - 1); + selection.setStartSelection(selection.getStartSelection() - 1); + setText(s + getSelectionAfterText()); + selection.reset(selection.getStartSelection()); + scrollToCursor(); + return true; + } + + return false; + } + + /** + * Handles keyboard shortcuts: clipboard operations (cut/copy/paste), + * undo/redo, tab indentation, code formatting, enter with brace handling, + * comment toggling, and line duplication. + */ + private boolean handleShortcutKeys(int i) { + // CTRL+X: Cut + if (this.isKeyComboCtrlX(i)) { + if (selection.hasSelection()) { + // Copy selected text into clipboard, then remove the selection + NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); + String s = getSelectionBeforeText(); + setText(s + getSelectionAfterText()); + selection.reset(s.length()); + scrollToCursor(); + } + return true; + } + + // CTRL+C: Copy + if (this.isKeyComboCtrlC(i)) { + if (selection.hasSelection()) { + NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); + } + return true; + } + + // CTRL+V: Paste (insert clipboard contents at caret) + if (this.isKeyComboCtrlV(i)) { + addText(NoppesStringUtils.getClipboardContents()); + scrollToCursor(); + return true; + } + + // UNDO (Ctrl+Z): restore last entry from undoList and push current state to redoList + if (i == Keyboard.KEY_Z && isCtrlKeyDown()) { + if (undoList.isEmpty()) + return false; // nothing to undo + undoing = true; + redoList.add(new UndoData(this.text, selection.getCursorPosition())); + UndoData data = undoList.remove(undoList.size() - 1); + setText(data.text); + selection.reset(data.cursorPosition); + undoing = false; + scrollToCursor(); + return true; + } + + // REDO (Ctrl+Y): opposite of undo + if (i == Keyboard.KEY_Y && isCtrlKeyDown()) { + if (redoList.isEmpty()) + return false; + undoing = true; + undoList.add(new UndoData(this.text, selection.getCursorPosition())); + UndoData data = redoList.remove(redoList.size() - 1); + setText(data.text); + selection.reset(data.cursorPosition); + undoing = false; + scrollToCursor(); + return true; + } + + // CTRL+F: format the text according to IndentHelper rules + if (i == Keyboard.KEY_F && isCtrlKeyDown()) { + formatText(); + return true; + } + + // CTRL+/ : toggle comment for selection or current line + if (i == Keyboard.KEY_SLASH && isCtrlKeyDown()) { + if (selection.hasSelection()) { + toggleCommentSelection(); + } else { + toggleCommentLineAtCursor(); + } + return true; + } + + // CTRL+D : duplicate selection or current line + if (i == Keyboard.KEY_D && isCtrlKeyDown()) { + if (selection.hasSelection()) { + // Multi-line selection duplication: find first and last covered lines, + // then insert the whole block after the last line without adding extra newline. + LineData firstLine = null, lastLine = null; + for (LineData line : container.lines) { + if (line.end > selection.getStartSelection() && line.start < selection.getEndSelection()) { + if (firstLine == null) firstLine = line; + lastLine = line; + } + } + if (firstLine != null && lastLine != null) { + String selectedText = text.substring(firstLine.start, lastLine.end); + String insertText = selectedText; + int savedStart = selection.getStartSelection(); + int savedEnd = selection.getEndSelection(); + int insertAt = lastLine.end; + setText(text.substring(0, insertAt) + insertText + text.substring(insertAt)); + // Restore prior selection / cursor positions + selection.setStartSelection(savedStart); + selection.setEndSelection(savedEnd); + selection.setCursorPositionDirect(savedEnd); + return true; + } + } else { + // Duplicate current line when nothing is selected + for (LineData line : container.lines) { + if (selection.getCursorPosition() >= line.start && selection.getCursorPosition() <= line.end) { + int lineStart = line.start, lineEnd = line.end; + String lineText = text.substring(lineStart, lineEnd); + // If the line already ends with a newline, reuse it; otherwise + // prefix a newline so duplicate appears after current line. + String insertText; + if (lineText.endsWith("\n")) { + insertText = lineText; + } else { + insertText = "\n" + lineText; + } + int insertionPoint = lineEnd; + setText(text.substring(0, insertionPoint) + insertText + text.substring(insertionPoint)); + int newCursor = insertionPoint + insertText.length() - (insertText.endsWith("\n") ? 1 : 0); + selection.reset(Math.max(0, Math.min(newCursor, this.text.length()))); + return true; + } + } + } + return true; + } + + return false; + } + + /** + * Handles printable character input with auto-pairing for quotes and brackets, + * and smart skipping over existing closing characters. + */ + private boolean handleCharacterInput(char c) { + if (ChatAllowedCharacters.isAllowedCharacter(c)) { + String before = getSelectionBeforeText(); + String after = getSelectionAfterText(); + + // If the user types a closing character and that same closer is + // already immediately after the caret, move caret past it instead + // of inserting another closer. This prevents duplicate closers + // when the editor auto-inserts pairs. + if ((c == ')' || c == ']' || c == '"' || c == '\'' ) && after.length() > 0 && after.charAt(0) == c) { + // Move caret forward by one (skip over existing closer) + selection.reset(before.length() + 1); + scrollToCursor(); + return true; + } + + // Auto-pair insertion: when opening a quote/brace/bracket is typed, + // insert a matching closer and place caret between the pair. + if (c == '"') { + setText(before + "\"\"" + after); + selection.reset(before.length() + 1); + scrollToCursor(); + return true; + } + if (c == '\'') { + setText(before + "''" + after); + selection.reset(before.length() + 1); + scrollToCursor(); + return true; + } + if (c == '[') { + setText(before + "[]" + after); + selection.reset(before.length() + 1); + scrollToCursor(); + return true; + } + if (c == '(') { + setText(before + "()" + after); + selection.reset(before.length() + 1); + scrollToCursor(); + return true; + } + + // Default insertion for printable characters: insert at caret (replacing selection) + addText(Character.toString(c)); + scrollToCursor(); + return true; + } + return false; + } + + private boolean isShiftKeyDown() { + return Keyboard.isKeyDown(42) || Keyboard.isKeyDown(54); + } + + // ==================== COMMENT TOGGLING ==================== + // Uses CommentHandler helper for comment operations + + private void toggleCommentSelection() { + CommentHandler.SelectionToggleResult result = CommentHandler.toggleCommentSelection( + text, container.lines, selection.getStartSelection(), selection.getEndSelection()); + setText(result.newText); + selection.setStartSelection(result.newStartSelection); + selection.setEndSelection(result.newEndSelection); + } + + private void toggleCommentLineAtCursor() { + CommentHandler.SingleLineToggleResult result = CommentHandler.toggleCommentAtCursor( + text, container.lines, selection.getCursorPosition()); + setText(result.newText); + setCursor(result.newCursorPosition, false); + } + + public boolean closeOnEsc(){ + return !KEYS_OVERLAY.isVisible() && !searchBar.isVisible() && !goToLineDialog.isVisible() && !renameHandler.isActive(); + } + + // ==================== KEYBOARD MODIFIERS ==================== + + private boolean isAltKeyDown() { + return Keyboard.isKeyDown(56) || Keyboard.isKeyDown(184); + } + + private boolean isKeyComboCtrlX(int keyID) { + return keyID == 45 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown(); + } + + private boolean isKeyComboCtrlBackspace(int keyID) { + return keyID == Keyboard.KEY_BACK && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown(); + } + + private boolean isKeyComboCtrlV(int keyID) { + return keyID == 47 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown(); + } + + private boolean isKeyComboCtrlC(int keyID) { + return keyID == 46 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown(); + } + + private boolean isKeyComboCtrlA(int keyID) { + return keyID == 30 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown(); + } + + private String getIndentCurrentLine() { + for (LineData data : this.container.lines) { + if (selection.getCursorPosition() > data.start && selection.getCursorPosition() <= data.end) { + int i; + for (i = 0; i < data.text.length() && data.text.charAt(i) == ' '; ++i) { + } + return data.text.substring(0, i); + } + } + return ""; + } + + private String getAutoIndentForEnter() { + LineData currentLine = selection.findCurrentLine(container.lines); + + if (currentLine == null) { + return ""; + } + return IndentHelper.getAutoIndentForEnter(currentLine.text, selection.getCursorPosition() - currentLine.start); + } + // ==================== TEXT FORMATTING ==================== + + private int getTabSize() { + return IndentHelper.TAB_SIZE; + } + + private String repeatSpace(int count) { + return IndentHelper.spaces(count); + } + + private void formatText() { + // Calculate viewport width for line wrapping (account for gutter and scrollbar) + int viewportWidth = this.width - LINE_NUMBER_GUTTER_WIDTH - 10; + IndentHelper.FormatResult result = IndentHelper.formatText(text, selection.getCursorPosition(), viewportWidth); + setText(result.text); + selection.reset(Math.max(0, Math.min(result.cursorPosition, this.text.length()))); + } + + // ==================== TAB HANDLING ==================== + + private void handleTab() { + LineData currentLine = selection.findCurrentLine(container.lines); + if (currentLine == null) { + addText(" "); + return; + } + int tab = getTabSize(); + int indentLen = IndentHelper.getLineIndent(currentLine.text); + int textStartPos = currentLine.start + indentLen; + + if (selection.getCursorPosition() <= textStartPos) { + // Cursor before any text: if cursor is exactly at text start, move forward to next tab stop. + // If cursor is inside leading whitespace (before text start), choose nearest tab stop (tie -> forward). + int targetIndent; + if (selection.getCursorPosition() == textStartPos) { + targetIndent = ((indentLen / tab) + 1) * tab; + } else { + int remainder = indentLen % tab; + if (remainder == 0) { + targetIndent = indentLen + tab; // already aligned -> next level + } else { + int down = indentLen - remainder; + int up = indentLen + (tab - remainder); + int distDown = remainder; + int distUp = tab - remainder; + if (distUp < distDown) targetIndent = up; + else if (distUp > distDown) targetIndent = down; + else targetIndent = up; // tie -> forward + } + } + if (targetIndent < 0) targetIndent = 0; + String newIndent = repeatSpace(targetIndent); + String rest = currentLine.text.substring(indentLen); + String before = text.substring(0, currentLine.start); + int contentEnd = Math.min(currentLine.start + currentLine.text.length(), text.length()); + int sepEnd = Math.min(currentLine.end, text.length()); + String sep = contentEnd < sepEnd ? text.substring(contentEnd, sepEnd) : ""; + String after = text.substring(sepEnd); + setText(before + newIndent + rest + sep + after); + int newCursor = currentLine.start + targetIndent; + selection.reset(Math.min(newCursor, this.text.length())); + } else { + // Cursor is after start of text: insert spaces at cursor to move following text to next tab stop + int column = selection.getCursorPosition() - currentLine.start; + int targetColumn = ((column / tab) + 1) * tab; + int toInsert = Math.max(0, targetColumn - column); + if (toInsert > 0) { + String spaces = repeatSpace(toInsert); + addText(spaces); + } + } + } + + private void handleShiftTab() { + LineData currentLine = selection.findCurrentLine(container.lines); + if (currentLine == null) + return; + int tab = getTabSize(); + int indentLen = IndentHelper.getLineIndent(currentLine.text); + int textStartPos = currentLine.start + indentLen; + + if (selection.getCursorPosition() <= textStartPos) { + // Cursor before any text: reduce leading indent to previous tab stop + int targetIndent = Math.max(0, ((indentLen - 1) / tab) * tab); + String newIndent = repeatSpace(targetIndent); + String rest = currentLine.text.substring(indentLen); + String before = text.substring(0, currentLine.start); + int contentEnd = Math.min(currentLine.start + currentLine.text.length(), text.length()); + int sepEnd = Math.min(currentLine.end, text.length()); + String sep = contentEnd < sepEnd ? text.substring(contentEnd, sepEnd) : ""; + String after = text.substring(sepEnd); + setText(before + newIndent + rest + sep + after); + int newCursor = currentLine.start + targetIndent; + selection.reset(Math.min(newCursor, this.text.length())); + } else { + // Cursor after start of text: remove up to previous tab stop worth of spaces immediately before cursor + int column = selection.getCursorPosition() - currentLine.start; + int mod = column % tab; + int toRemove = mod == 0 ? tab : mod; + int removed = 0; + int pos = selection.getCursorPosition() - 1; + while (pos >= currentLine.start && removed < toRemove && text.charAt(pos) == ' ') { + pos--; + removed++; + } + if (removed > 0) { + int removeStart = pos + 1; + String before = text.substring(0, removeStart); + String after = text.substring(selection.getCursorPosition()); + setText(before + after); + int newCursor = removeStart; + selection.reset(Math.min(newCursor, this.text.length())); + } + } + } + + // ==================== CURSOR MANAGEMENT ==================== + + private void setCursor(int i, boolean select) { + selection.setCursor(i, text != null ? text.length() : 0, select); + } + + private void addText(String s) { + int insertPos = selection.getStartSelection(); + this.setText(this.getSelectionBeforeText() + s + this.getSelectionAfterText()); + selection.afterTextInsert(insertPos + s.length()); + } + + private int cursorUp() { + return CursorNavigation.cursorUp(selection.getCursorPosition(), container.lines, text); + } + + private int cursorDown() { + return CursorNavigation.cursorDown(selection.getCursorPosition(), container.lines, text); + } + + public String getSelectionBeforeText() { + return selection.getTextBefore(text); + } + + public String getSelectionAfterText() { + return selection.getTextAfter(text); + } + + // ==================== MOUSE HANDLING ==================== + + public void mouseClicked(int xMouse, int yMouse, int mouseButton) { + // Check go to line dialog clicks first + if (goToLineDialog.isVisible() && goToLineDialog.mouseClicked(xMouse, yMouse, mouseButton)) { + return; + } + + // Check search bar clicks first + if (searchBar.isVisible() && searchBar.mouseClicked(xMouse, yMouse, mouseButton)) { + return; + } + + // If search bar is visible but click was outside it, unfocus the search bar + if (searchBar.isVisible()) { + searchBar.unfocus(); + } + + // Let the overlay consume clicks (it returns true when it handled the event) + if (KEYS_OVERLAY.mouseClicked(xMouse, yMouse, mouseButton)) + return; + + // Determine whether click occurred inside the text area bounds + this.active = xMouse >= this.x && xMouse < this.x + this.width && yMouse >= this.y && yMouse < this.y + this.height; + if (this.active) { + // Compute logical click position in text + int clickPos = this.getSelectionPos(xMouse, yMouse); + + // Check if rename refactoring is active and click is in the rename box + if (renameHandler.isActive() && renameHandler.handleClick(clickPos)) { + // Click was handled by rename handler - don't reset selection or do other click handling + this.clicked = false; + activeTextfield = this; + return; + } + + // Normal click handling - reset selection/caret + selection.reset(clickPos); + selection.markActivity(); + + // Prepare click state (left button starts most interactions) + this.clicked = mouseButton == 0; + this.doubleClicked = false; + this.tripleClicked = false; + long time = System.currentTimeMillis(); + + // Prefer delegating scrollbar-start logic to ScrollState. If the click + // is on the scrollbar area and the scrollbar can be dragged, we start + // click-scrolling mode and cancel the normal text click-drag behavior. + if (this.clicked && getPaddedLineCount() * this.container.lineHeight > this.height && xMouse > this.x + this.width - 8) { + // We consumed the mouse-down as a scrollbar drag start + this.clicked = false; + scroll.startScrollbarDrag(yMouse,this.y,this.height, getPaddedLineCount()); + } else { + // Handle double/triple click selection counting + if (time - this.lastClicked < 300L) { + this.clickCount++; + } else { + this.clickCount = 1; + } + + if (this.clickCount == 2) { + // Double-click: select the word under the caret using the container's word regex + this.doubleClicked = true; + selection.selectWordAtCursor(this.text, this.container.regexWord); + // Prevent subsequent mouse-drag handling in the render loop from + // treating this double-click as a normal click-drag which would + // immediately reset and extend the selection. Clearing `clicked` + // keeps the double-click selection stable. + this.clicked = false; + } else if (this.clickCount >= 3) { + // Triple-click: select the entire logical line that contains the caret + this.tripleClicked = true; + selection.selectLineAtCursor(container.lines); + // Same as double-click: clear `clicked` to avoid accidental drag-extension + this.clicked = false; + this.clickCount = 0; + } + } + + this.lastClicked = time; + activeTextfield = this; + } + } + + // Called from GuiScreen.updateScreen() + public void updateCursorCounter() { + // Only process KeyPresets if search bar and go-to-line dialog don't have focus + // This prevents COPY, PASTE, UNDO, etc. from firing when typing in dialogs + KEYS.tick(); + + searchBar.updateCursor(); + goToLineDialog.updateCursor(); + renameHandler.updateCursor(); + ++this.cursorCounter; + } + + // ==================== TEXT MANAGEMENT ==================== + + public void setText(String text) { + if (text == null) { + return; + } + + text = text.replace("\r", ""); + text = text.replace("\t", " "); + // preserve trailing newlines here — JavaTextContainer.init will + // ignore an extra empty final split when rendering lines. + if (this.text == null || !this.text.equals(text)) { + if (this.listener != null) { + this.listener.textUpdate(text); + } + + if (!this.undoing) { + this.undoList.add(new GuiScriptTextArea1.UndoData(this.text, selection.getCursorPosition())); + this.redoList.clear(); + } + + this.text = text; + //this.container = new TextContainer(text); + if (this.container == null) + this.container = new ScriptTextContainer(text); + + this.container.init(text, this.width, this.height); + + if (this.enableCodeHighlighting) + this.container.formatCodeText(); + + // Ensure scroll state stays in bounds after text change + int maxScroll = Math.max(0, getPaddedLineCount() - this.container.visibleLines); + scroll.clampToBounds(maxScroll); + + selection.clamp(this.text.length()); + + // Consider text changes user activity to pause caret blinking briefly + selection.markActivity(); + searchBar.updateMatches(); + + } + } + + public String getText() { + return this.text; + } + + public boolean isEnabled() { + return this.enabled && this.visible; + } + + public boolean hasVerticalScrollbar() { + return this.container != null && this.container.visibleLines < getPaddedLineCount(); + } + + public void enableCodeHighlighting() { + this.enableCodeHighlighting = true; + this.container.formatCodeText(); + } + + public void setListener(ITextChangeListener listener) { + this.listener = listener; + } + + private void clampSelectionBounds() { + selection.clamp(text != null ? text.length() : 0); + } + + // ==================== INNER CLASSES ==================== + + public static class UndoData { + public String text; + public int cursorPosition; + + public UndoData(String text, int cursorPosition) { + this.text = text; + this.cursorPosition = cursorPosition; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiUtil.java b/src/main/java/noppes/npcs/client/gui/util/GuiUtil.java index 7350668b8..5163502ba 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiUtil.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiUtil.java @@ -159,6 +159,32 @@ public static void drawTexturedModalRect(double x, double y, double width, doubl tessellator.draw(); } + /** + * Draw a scaled textured rectangle (for rendering icons). + */ + public static void drawScaledTexturedRect(int x, int y, int u, int v, int srcWidth, int srcHeight, + int destWidth, int destHeight, int textureWidth, int textureHeight) { + float uScale = 1.0f / textureWidth; + float vScale = 1.0f / textureHeight; + + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + + GL11.glBegin(GL11.GL_QUADS); + GL11.glTexCoord2f(u * uScale, v * vScale); + GL11.glVertex2f(x, y); + GL11.glTexCoord2f(u * uScale, (v + srcHeight) * vScale); + GL11.glVertex2f(x, y + destHeight); + GL11.glTexCoord2f((u + srcWidth) * uScale, (v + srcHeight) * vScale); + GL11.glVertex2f(x + destWidth, y + destHeight); + GL11.glTexCoord2f((u + srcWidth) * uScale, v * vScale); + GL11.glVertex2f(x + destWidth, y); + GL11.glEnd(); + + GL11.glDisable(GL11.GL_BLEND); + } + + public static void setMouse(int guiX, int guiY) { Minecraft mc = Minecraft.getMinecraft(); ScaledResolution scaledResolution = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); diff --git a/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java b/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java index 844da165b..9b6567b1e 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java @@ -375,7 +375,8 @@ public ClassInfo resolveSimpleName(String simpleName, Map import if (p == null || p.isEmpty()) continue; String[] segs = p.split("\\."); String last = segs[segs.length - 1]; - boolean lastIsClass = last.length() > 0 && Character.isUpperCase(last.charAt(0)); + // Check if last segment is actually a class (try to resolve it) + boolean lastIsClass = last.length() > 0 && tryResolveClass(p) != null; // Try package wildcard: pkg.SimpleName if (!lastIsClass) { @@ -539,6 +540,8 @@ private List buildClassSegments(String[] segments, int classStartI private int findFirstUppercaseSegment(String[] segments) { for (int i = 0; i < segments.length; i++) { if (segments[i].length() > 0 && Character.isUpperCase(segments[i].charAt(0))) { + // Keep uppercase check here as it's a heuristic for package splitting + // This is about Java naming conventions, not actual type resolution return i; } } @@ -653,10 +656,10 @@ private void parseGenericTypesRecursive(String content, int baseOffset, } String typeName = content.substring(start, i); - // Only process if it looks like a type name (starts with uppercase) - if (Character.isUpperCase(typeName.charAt(0))) { - // Resolve the type - ClassInfo info = resolveSimpleName(typeName, importedClasses, importedPackages); + // Try to resolve as an actual type + ClassInfo info = resolveSimpleName(typeName, importedClasses, importedPackages); + if (info != null) { + // Only process if it actually resolves to a type ClassType classType = (info != null) ? info.type : ClassType.CLASS; results.add(new TypeOccurrence(baseOffset + start, baseOffset + i, typeName, classType)); diff --git a/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java b/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java index a1eedce0b..c2eb5592a 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java @@ -625,9 +625,14 @@ private boolean isLikelyGeneric(String code, int pos, char bracket) { start--; } String before = code.substring(start, pos); - // Common generic types or capital letter start (type name) - if (!before.isEmpty() && Character.isUpperCase(before.charAt(0))) { - return true; + // Check if it's actually a type name (more robust than just checking uppercase) + if (!before.isEmpty()) { + // For FormatHelper, we don't have access to ScriptDocument's resolveType, + // so we check common patterns: uppercase start or known generic types + if (Character.isUpperCase(before.charAt(0)) || + before.equals("var") || before.equals("let") || before.equals("const")) { + return true; + } } } // Check if there's a matching > diff --git a/src/main/java/noppes/npcs/client/gui/util/script/RenameRefactorHandler.java b/src/main/java/noppes/npcs/client/gui/util/script/RenameRefactorHandler.java index 81c346f8f..cf6adb9e2 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/RenameRefactorHandler.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/RenameRefactorHandler.java @@ -2,6 +2,12 @@ import net.minecraft.client.gui.GuiScreen; import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocParamTag; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; import org.lwjgl.input.Keyboard; import java.util.ArrayList; @@ -11,7 +17,7 @@ /** * Handles IntelliJ-like rename refactoring with scope-aware renaming. - *

+ * * Features: * - Scope detection: local variables rename only within their block, global fields rename everywhere * - Visual feedback: primary occurrence has white border, others just highlighted @@ -32,7 +38,11 @@ public class RenameRefactorHandler { private int primaryOccurrenceEnd = -1; private List allOccurrences = new ArrayList<>(); private ScopeInfo scope = null; - + + // For parameter renaming: track containing method to find JSDoc + private boolean isParameterRename = false; + private MethodInfo containingMethod = null; + // For global scope, track positions that have local shadowing (to exclude them) private List localShadowedPositions = new ArrayList<>(); @@ -82,7 +92,7 @@ public interface RenameCallback { void scrollToPosition(int pos); - JavaTextContainer getContainer(); + ScriptTextContainer getContainer(); int getViewportWidth(); // For determining if clicks are in rename box } @@ -140,29 +150,62 @@ public boolean startRename() { initialWord = text.substring(wordBounds[0], wordBounds[1]); // Remember for undo originalWord = initialWord; currentWord = initialWord; - + // Determine scope for this identifier - MUST check if local shadows global scope = determineScope(text, wordBounds[0], originalWord, callback.getContainer()); - + + // Track if this is a parameter rename by checking if position is in any method's parameter declaration + isParameterRename = false; + containingMethod = null; + + ScriptTextContainer container = callback.getContainer(); + if (container != null && container.getDocument() != null) { + ScriptDocument document = container.getDocument(); + List methods = document.getAllMethods(); + + // Check if the word at cursor position is a parameter of any method + // A rename is a parameter rename if: + // 1. The word matches a parameter name + // 2. The cursor is within that method's declaration or body + for (MethodInfo method : methods) { + // Check if cursor is within this method's range + int methodStart = method.getFullDeclarationOffset(); + int methodEnd = method.getBodyEnd(); + if (methodStart < 0 || methodEnd < 0) continue; // External method + + if (wordBounds[0] >= methodStart && wordBounds[0] <= methodEnd) { + // Check if the word matches any parameter of this method + for (FieldInfo param : method.getParameters()) { + if (param.getName().equals(originalWord)) { + isParameterRename = true; + containingMethod = method; + break; + } + } + if (isParameterRename) break; + } + } + } + // If global scope, find all positions where local variables shadow this name localShadowedPositions.clear(); if (scope != null && scope.isGlobal) { findLocalShadowedPositions(text, originalWord); } - // Find all occurrences within scope + // Find all occurrences within scope (including JSDoc if it's a parameter) findOccurrences(text, originalWord); if (allOccurrences.isEmpty()) return false; active = true; - + // Set selection to full word (uses GuiScriptTextArea's selection) primaryOccurrenceStart = wordBounds[0]; primaryOccurrenceEnd = wordBounds[1]; callback.getSelectionState().setSelection(wordBounds[0], wordBounds[1]); - // callback.setCursorPosition(wordBounds[1]); + // callback.setCursorPosition(wordBounds[1]); callback.getSelectionState().markActivity(); return true; @@ -206,7 +249,7 @@ public void confirm() { // During live editing we used setTextWithoutUndo to avoid creating intermediate // undo entries; pushing the original snapshot here gives a single predictable // undo step back to the pre-rename state. - if (originalText != null && !originalText.isEmpty()) + if (originalText != null && !originalText.isEmpty()) callback.pushUndoState(originalText, originalCursorPos); // Calculate cursor position relative to primary occurrence @@ -230,13 +273,14 @@ private void resetState() { allOccurrences.clear(); localShadowedPositions.clear(); scope = null; + isParameterRename = false; + containingMethod = null; originalText = ""; originalCursorPos = 0; } /** * Handle keyboard input during rename - * * @return true if input was consumed */ public boolean keyTyped(char c, int keyCode) { @@ -264,7 +308,7 @@ public boolean keyTyped(char c, int keyCode) { // Get selection state SelectionState sel = callback.getSelectionState(); boolean hasSelection = sel.hasSelection(); - + // Calculate cursor position within the word int cursorInWord = callback.getCursorPosition() - primaryOccurrenceStart; cursorInWord = Math.max(0, Math.min(cursorInWord, currentWord.length())); @@ -315,7 +359,7 @@ public boolean keyTyped(char c, int keyCode) { sel.setSelection(primaryOccurrenceStart, primaryOccurrenceStart); callback.setCursorPosition(primaryOccurrenceStart); } - + // Left arrow if (keyCode == Keyboard.KEY_LEFT) { if (cursorInWord > 0) { @@ -354,7 +398,7 @@ public boolean keyTyped(char c, int keyCode) { // Ctrl+A - select all (select the whole word) if (keyCode == Keyboard.KEY_A && - Keyboard.isKeyDown(Keyboard.KEY_LCONTROL)) { + Keyboard.isKeyDown(Keyboard.KEY_LCONTROL)) { sel.setSelection(primaryOccurrenceStart, primaryOccurrenceStart + currentWord.length()); callback.setCursorPosition(primaryOccurrenceStart + currentWord.length()); sel.markActivity(); @@ -536,23 +580,86 @@ private void findOccurrences(String text, String word) { allOccurrences.add(new int[]{start, end}); } } + + // If this is a parameter rename, also look for JSDoc @param occurrences + if (isParameterRename && containingMethod != null) { + findJSDocParamOccurrences(text, word); + } } - + + /** + * Find JSDoc @param occurrences for a parameter name. + * Uses the JSDocInfo stored in the MethodInfo to locate the JSDoc comment, + * then searches for the parameter name pattern in the current text. + */ + private void findJSDocParamOccurrences(String text, String paramName) { + if (containingMethod == null) return; + + // Get the JSDocInfo from the method to verify it has a @param for our parameter + JSDocInfo jsDocInfo = containingMethod.getJSDocInfo(); + if (jsDocInfo == null) return; + + // Check that this parameter has a JSDoc @param tag (using initial word to find the tag definition) + JSDocParamTag paramTag = jsDocInfo.getParamTag(initialWord); + if (paramTag == null) return; + + // Get the approximate JSDoc location from the original document + int originalJsDocStart = jsDocInfo.getStartOffset(); + int originalJsDocEnd = jsDocInfo.getEndOffset(); + if (originalJsDocStart < 0 || originalJsDocEnd < 0) return; + + // Calculate how much positions have shifted due to renames before the JSDoc + // Count occurrences that are currently in the text before the original JSDoc start + int shiftBeforeJsDoc = 0; + int lengthDiff = paramName.length() - initialWord.length(); + + // The occurrences we've already found (code occurrences) - count how many are before the JSDoc + for (int[] occ : allOccurrences) { + // Map current position back to approximate original position + int approxOriginalPos = occ[0] - shiftBeforeJsDoc; + if (approxOriginalPos < originalJsDocStart) { + shiftBeforeJsDoc += lengthDiff; + } + } + + // Now search for the @param pattern in the current text around where the JSDoc should be + int adjustedJsDocStart = originalJsDocStart + shiftBeforeJsDoc; + int adjustedJsDocEnd = originalJsDocEnd + shiftBeforeJsDoc; + + // Clamp to text bounds + adjustedJsDocStart = Math.max(0, adjustedJsDocStart); + adjustedJsDocEnd = Math.min(text.length(), adjustedJsDocEnd + 50); // Add some buffer for expanded text + + if (adjustedJsDocStart >= adjustedJsDocEnd) return; + + String jsDocRegion = text.substring(adjustedJsDocStart, adjustedJsDocEnd); + + // Search for @param {Type} paramName or @param paramName pattern + Pattern pattern = Pattern.compile("@param\\s+(?:\\{[^}]*\\}\\s+)?(" + Pattern.quote(paramName) + ")(?:\\s|$|-)"); + Matcher m = pattern.matcher(jsDocRegion); + + while (m.find()) { + int paramNameStart = adjustedJsDocStart + m.start(1); + int paramNameEnd = adjustedJsDocStart + m.end(1); + allOccurrences.add(new int[]{paramNameStart, paramNameEnd}); + } + } + /** * Find all positions where local variables with the same name shadow the global variable. * This populates localShadowedPositions with ranges where local declarations exist. */ private void findLocalShadowedPositions(String text, String varName) { localShadowedPositions.clear(); - + List methods = MethodBlock.collectMethodBlocks(text); List excluded = MethodBlock.getExcludedRanges(text); - + for (MethodBlock method : methods) { // Check if this method has a local variable or parameter with the same name boolean hasLocalDecl = method.localVariables.contains(varName); boolean hasParamDecl = isParameterInMethod(text, varName, method); - + if (hasLocalDecl || hasParamDecl) { // Find where the local/param scope starts int scopeStart; @@ -564,18 +671,18 @@ private void findLocalShadowedPositions(String text, String varName) { scopeStart = findLocalDeclarationPosition(text, varName, method); if (scopeStart < 0) scopeStart = method.startOffset; } - + // Find all occurrences of varName within this method's shadowed range Pattern pattern = Pattern.compile("\\b" + Pattern.quote(varName) + "\\b"); Matcher m = pattern.matcher(text); - + while (m.find()) { int pos = m.start(); - + // Skip if in string or comment if (isInExcludedRange(pos, excluded)) continue; - + // Check if within the shadowed scope of this method if (pos >= scopeStart && pos < method.endOffset) { localShadowedPositions.add(new int[]{m.start(), m.end()}); @@ -584,7 +691,7 @@ private void findLocalShadowedPositions(String text, String varName) { } } } - + /** * Check if a position is within a locally shadowed range */ @@ -595,13 +702,13 @@ private boolean isInLocalShadowedRange(int pos) { } return false; } - + /** * Check if varName is a parameter in the method */ private boolean isParameterInMethod(String text, String varName, MethodBlock method) { String methodHeader = text.substring(method.startOffset, - Math.min(method.startOffset + 500, method.endOffset)); + Math.min(method.startOffset + 500, method.endOffset)); int parenStart = methodHeader.indexOf('('); int parenEnd = methodHeader.indexOf(')'); if (parenStart >= 0 && parenEnd > parenStart) { @@ -687,7 +794,7 @@ private ScopeInfo determineScope(String text, int position, String varName, Java // Cursor is inside a method // Check if it's a method parameter first String methodHeader = text.substring(containingMethod.startOffset, - Math.min(containingMethod.startOffset + 500, containingMethod.endOffset)); + Math.min(containingMethod.startOffset + 500, containingMethod.endOffset)); int parenStart = methodHeader.indexOf('('); int parenEnd = methodHeader.indexOf(')'); if (parenStart >= 0 && parenEnd > parenStart) { @@ -861,9 +968,9 @@ public int getCursorInWord() { public boolean isWordFullySelected() { if (callback == null) return false; SelectionState selection = callback.getSelectionState(); - return selection.hasSelection() && - selection.getStartSelection() == primaryOccurrenceStart && - selection.getEndSelection() == primaryOccurrenceEnd; + return selection.hasSelection() && + selection.getStartSelection() == primaryOccurrenceStart && + selection.getEndSelection() == primaryOccurrenceEnd; } /** @@ -884,7 +991,7 @@ public String getCurrentWord() { public boolean handleClick(int clickPosInText) { if (!active || callback == null) return false; - + return false; } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java b/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java index 4d71a6b62..f8c92d64a 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java @@ -21,12 +21,12 @@ public class ScrollState { private int scrollbarDragOffset = 0; // Whether currently dragging the scrollbar private boolean clickScrolling = false; - + // Animation parameters - private static final double TAU = 0.1; // Time constant (~55ms feels snappy) - private static final double SNAP_THRESHOLD = 0.01; // Snap to target when this close - private static final double MAX_DT = 0.05; // Max delta time for stability - + private static final double TAU = 0.15; // Time constant (~55ms feels snappy) + private static final double SNAP_THRESHOLD = 0.1; // Snap to target when this close (increased to avoid microstutters) + private static final double MAX_DT = 0.1; // Max delta time for stability + /** * Reset scroll state to initial values */ @@ -37,7 +37,7 @@ public void reset() { scrolledLine = 0; lastScrollTime = System.currentTimeMillis(); } - + /** * Initialize scroll position if not already initialized */ @@ -47,19 +47,18 @@ public void initializeIfNeeded(int currentLine) { lastScrollTime = System.currentTimeMillis(); } } - + /** * Update scroll animation. Call once per frame. - * * @param maxScroll Maximum valid scroll position */ public void update(int maxScroll) { long nowMs = System.currentTimeMillis(); double dt = Math.min(MAX_DT, (nowMs - lastScrollTime) / 1000.0); lastScrollTime = nowMs; - + double dist = targetScroll - scrollPos; - + // Snap to target if close enough if (Math.abs(dist) < SNAP_THRESHOLD) { scrollPos = targetScroll; @@ -70,21 +69,21 @@ public void update(int maxScroll) { double prev = scrollPos; scrollPos += dist * alpha; scrollVelocity = (scrollPos - prev) / (dt > 0 ? dt : 1e-6); - + // Clamp overshoot if ((dist > 0 && scrollPos > targetScroll) || (dist < 0 && scrollPos < targetScroll)) { scrollPos = targetScroll; scrollVelocity = 0.0; } } - + // Keep in bounds clampToBounds(maxScroll); - + // Update integer line for rendering scrolledLine = Math.max(0, Math.min((int) Math.floor(scrollPos), maxScroll)); } - + /** * Clamp scroll position to valid bounds */ @@ -95,37 +94,35 @@ public void clampToBounds(int maxScroll) { if (targetScroll > maxScroll) targetScroll = maxScroll; scrolledLine = Math.max(0, Math.min(scrolledLine, maxScroll)); } - + /** * Apply mouse wheel scroll - * * @param wheelDelta Positive = scroll up, negative = scroll down - * @param maxScroll Maximum valid scroll position + * @param maxScroll Maximum valid scroll position */ public void applyWheelScroll(int wheelDelta, int maxScroll) { double sign = Math.copySign(1, wheelDelta); targetScroll -= sign * 2.0; // 2 lines per wheel tick clampToBounds(maxScroll); } - + /** * Set target scroll directly (for scrollbar dragging) */ public void setTargetScroll(double target, int maxScroll) { targetScroll = Math.max(0, Math.min(target, maxScroll)); } - + /** * Scroll to make a specific line visible - * - * @param lineIdx Line index to make visible + * @param lineIdx Line index to make visible * @param visibleLines Number of visible lines in viewport - * @param maxScroll Maximum scroll position + * @param maxScroll Maximum scroll position */ public void scrollToLine(int lineIdx, int visibleLines, int maxScroll) { int firstVisible = scrolledLine; int lastFullyVisible = scrolledLine + visibleLines; - + if (lineIdx < firstVisible) { // Line is above viewport - scroll up targetScroll = lineIdx; @@ -135,66 +132,41 @@ public void scrollToLine(int lineIdx, int visibleLines, int maxScroll) { } // If line is visible, don't change scroll } - + // Getters - public double getScrollPos() { - return scrollPos; - } + public double getScrollPos() { return scrollPos; } + public double getTargetScroll() { return targetScroll; } - public double getTargetScroll() { - return targetScroll; - } - - /** - * @return First visible line - **/ + /** @return First visible line **/ public int getScrolledLine() { return scrolledLine; } - - public double getScrollVelocity() { - return scrollVelocity; - } - - public boolean isClickScrolling() { - return clickScrolling; - } - - public int getScrollbarDragOffset() { - return scrollbarDragOffset; - } - + public double getScrollVelocity() { return scrollVelocity; } + public boolean isClickScrolling() { return clickScrolling; } + public int getScrollbarDragOffset() { return scrollbarDragOffset; } + /** * Get fractional offset for sub-pixel rendering */ public double getFractionalOffset() { return scrollPos - scrolledLine; } - + // Setters for scrollbar interaction - public void setClickScrolling(boolean scrolling) { - this.clickScrolling = scrolling; - } - - public void setScrollbarDragOffset(int offset) { - this.scrollbarDragOffset = offset; - } - - public void setScrolledLine(int line) { - this.scrolledLine = line; - } + public void setClickScrolling(boolean scrolling) { this.clickScrolling = scrolling; } + public void setScrollbarDragOffset(int offset) { this.scrollbarDragOffset = offset; } + public void setScrolledLine(int line) { this.scrolledLine = line; } /** * Handle scrollbar dragging interaction. This encapsulates the logic that was * previously in the GUI class for handling thumb clicks/drags. - * - * @param yMouse current mouse Y coordinate - * @param areaX GUI area X (unused but kept for parity) - * @param areaY GUI area Y (top of text area) - * @param areaHeight height of the scroll track area + * @param yMouse current mouse Y coordinate + * @param areaX GUI area X (unused but kept for parity) + * @param areaY GUI area Y (top of text area) + * @param areaHeight height of the scroll track area * @param visibleLines number of visible lines in viewport - * @param linesCount total number of lines - * @param maxScroll maximum allowed scroll value + * @param linesCount total number of lines + * @param maxScroll maximum allowed scroll value */ public void handleClickScrolling(int yMouse, int areaX, int areaY, int areaHeight, int visibleLines, int linesCount, int maxScroll) { // Keep dragging while mouse button is down @@ -238,4 +210,4 @@ public void startScrollbarDrag(int yMouse, int areaY, int areaHeight, int linesC int thumbTop = (int) (areaY + 1f * getScrollPos() / linesCountD * (areaHeight - 4)) + 1; setScrollbarDragOffset(yMouse - thumbTop); } -} +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java new file mode 100644 index 000000000..707a1e664 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java @@ -0,0 +1,830 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSFieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSMethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticField; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticMethod; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticParameter; + +/** + * Represents a single autocomplete suggestion. + * Unified representation for both Java and JavaScript completions. + */ +public class AutocompleteItem implements Comparable { + + /** + * The kind of completion item. + */ + public enum Kind { + ENUM_CONSTANT(-1), // Enum constant value (highest priority for enums) + METHOD(0), // Method or function + FIELD(1), // Field or property + VARIABLE(2), // Local variable or parameter + CLASS(3), // Class or interface type + ENUM(4), // Enum type + KEYWORD(6), // Language keyword + SNIPPET(7); // Code snippet + + private final int priority; + + Kind(int priority) { + this.priority = priority; + } + + public int getPriority() { + return priority; + } + } + + private final String name; // Display name (e.g., "getPlayer" or "getPlayer(UUID id)" for methods) + private final String searchName; // Name to search against (just the identifier, e.g., "getPlayer") + private final String insertText; // Text to insert (e.g., "getPlayer()") + private final Kind kind; // Type of completion + private final String typeLabel; // Type label (e.g., "IPlayer", "void") + private final TypeInfo typeInfo; // TypeInfo for the typeLabel + private final String signature; // Full signature for methods + private final String documentation; // Documentation/description + private final Object sourceData; // Original source (MethodInfo, FieldInfo, etc.) + private final boolean deprecated; // Whether this item is deprecated + + // Import tracking + private final boolean requiresImport; // Whether selecting this item requires adding an import + private final String importPath; // Full path for import (e.g., "net.minecraft.client.Minecraft") + + // Inheritance tracking (for JS types) + private final int inheritanceDepth; // Depth in inheritance tree (0 = child, 1 = parent, 2 = grandparent, etc.) + + // Match scoring + private int matchScore = 0; // How well this matches the query + private int[] matchIndices; // Indices of matched characters for highlighting + + private AutocompleteItem(String name, String searchName, String insertText, Kind kind, String typeLabel, + TypeInfo typeLabelTypeInfo, String signature, String documentation, Object sourceData, + boolean deprecated, boolean requiresImport, String importPath, int inheritanceDepth) { + this.name = name; + this.searchName = searchName != null ? searchName : name; // Default to name if not provided + this.insertText = insertText; + this.kind = kind; + this.typeLabel = typeLabel; + this.typeInfo = typeLabelTypeInfo; + this.signature = signature; + this.documentation = documentation; + this.sourceData = sourceData; + this.deprecated = deprecated; + this.requiresImport = requiresImport; + this.importPath = importPath; + this.inheritanceDepth = inheritanceDepth; + } + + // ==================== FACTORY METHODS ==================== + + /** + * Create from a Java MethodInfo. + */ + public static AutocompleteItem fromMethod(MethodInfo method) { + return fromMethod(method, false); + } + + /** + * Create from a Java MethodInfo, optionally for method reference context. + * @param method The method info + * @param forMethodReference If true, insert text will NOT include parentheses + */ + public static AutocompleteItem fromMethod(MethodInfo method, boolean forMethodReference) { + String name = method.getName(); + StringBuilder insertText = new StringBuilder(name); + + // For method references, don't add () since it's ::methodName not ::methodName() + if (!forMethodReference) { + insertText.append("("); + // Add placeholders for parameters if any + if (method.getParameterCount() > 0) { + // Just add () - user will fill in params + } + insertText.append(")"); + } + + String returnType = method.getReturnType() != null ? + getName(method.getReturnType()) : "void"; + + String signature = buildMethodSignature(method); + + // Build display name with parameters like "read(byte[] b)" + StringBuilder displayName = new StringBuilder(name); + displayName.append("("); + for (int i = 0; i < method.getParameterCount(); i++) { + if (i > 0) displayName.append(", "); + FieldInfo param = method.getParameters().get(i); + String paramType = param.getTypeInfo() != null ? + getName(param.getTypeInfo()) : "?"; + displayName.append(paramType); + if (param.getName() != null && !param.getName().isEmpty()) { + displayName.append(" ").append(param.getName()); + } + } + displayName.append(")"); + + return new AutocompleteItem( + displayName.toString(), + name, // Search against just the method name, not the params + insertText.toString(), + Kind.METHOD, + returnType, + method.getReturnType(), // TypeInfo for return type + signature, + method.getDocumentation(), + method, + false, // TODO: Check for @Deprecated annotation + false, // Methods don't require imports + null, + -1 // Java methods don't use inheritance depth sorting + ); + } + + /** + * Create from a Java FieldInfo. + */ + public static AutocompleteItem fromField(FieldInfo field) { + String typeLabel = field.getTypeInfo() != null ? + getName(field.getTypeInfo()) : "?"; + + Kind kind; + switch (field.getScope()) { + case PARAMETER: + kind = Kind.VARIABLE; + break; + case LOCAL: + kind = Kind.VARIABLE; + break; + case ENUM_CONSTANT: + kind = Kind.ENUM_CONSTANT; + break; + default: + kind = Kind.FIELD; + } + + return new AutocompleteItem( + field.getName(), + field.getName(), // searchName same as display name for fields + field.getName(), + kind, + typeLabel, + field.getTypeInfo(), // TypeInfo for field type + typeLabel + " " + field.getName(), + null, + field, + false, + false, // Fields don't require imports + null, + -1// Java fields don't use inheritance depth sorting + ); + } + + /** + * Create from a Java TypeInfo. + */ + public static AutocompleteItem fromType(TypeInfo type) { + Kind kind; + switch (type.getKind()) { + case INTERFACE: + kind = Kind.CLASS; + break; + case ENUM: + kind = Kind.ENUM; + break; + default: + kind = Kind.CLASS; + } + String name = getName(type); + return new AutocompleteItem( + name, + name, // searchName same as display name for types + name, + kind, + type.getPackageName(), + type, // TypeInfo for package name + type.getFullName(), + null, + type, + false, + false, // Will be overridden for unimported types + null, + -1 // Java types don't use inheritance depth sorting + ); + } + + /** + * Create from a JavaScript JSMethodInfo. + */ + public static AutocompleteItem fromJSMethod(JSMethodInfo method) { + return fromJSMethod(method, null, 0, false); + } + + /** + * Create from a JavaScript JSMethodInfo with inheritance depth. + * @param method The method info + * @param inheritanceDepth Depth in inheritance tree (0 = child, 1 = parent, etc.) + */ + public static AutocompleteItem fromJSMethod(JSMethodInfo method, int inheritanceDepth) { + return fromJSMethod(method, null, inheritanceDepth, false); + } + + /** + * Create from a JavaScript JSMethodInfo with type parameter resolution for display. + * @param method The method info + * @param contextType The TypeInfo context for resolving type parameters (e.g., IPlayer to resolve T → EntityPlayerMP) + * @param inheritanceDepth Depth in inheritance tree (0 = child, 1 = parent, etc.) + */ + public static AutocompleteItem fromJSMethod(JSMethodInfo method, TypeInfo contextType, int inheritanceDepth) { + return fromJSMethod(method, contextType, inheritanceDepth, false); + } + + /** + * Create from a JavaScript JSMethodInfo with type parameter resolution for display. + * @param method The method info + * @param contextType The TypeInfo context for resolving type parameters (e.g., IPlayer to resolve T → EntityPlayerMP) + * @param inheritanceDepth Depth in inheritance tree (0 = child, 1 = parent, etc.) + * @param forMethodReference If true, insert text will NOT include parentheses + */ + public static AutocompleteItem fromJSMethod(JSMethodInfo method, TypeInfo contextType, int inheritanceDepth, boolean forMethodReference) { + String name = method.getName(); + StringBuilder insertText = new StringBuilder(name); + + // For method references, don't add () since it's ::methodName not ::methodName() + if (!forMethodReference) { + insertText.append("("); + insertText.append(")"); + } + + // Build display name with resolved parameter types + StringBuilder displayName = new StringBuilder(name); + displayName.append("("); + for (int i = 0; i < method.getParameters().size(); i++) { + if (i > 0) displayName.append(", "); + JSMethodInfo.JSParameterInfo param = method.getParameters().get(i); + + // Use resolved type with full generic display + TypeInfo paramType = param.getResolvedType(contextType); + String paramTypeName = paramType.isResolved() ? paramType.getDisplayName() : param.getType(); + displayName.append(paramTypeName).append(" ").append(param.getName()); + } + displayName.append(")"); + + // Get return type using the new method with full generic display + TypeInfo returnTypeInfo = method.getResolvedReturnType(contextType); + String returnType = returnTypeInfo.isResolved() ? returnTypeInfo.getDisplayName() : method.getReturnType(); + + return new AutocompleteItem( + displayName.toString(), + name, // Search against just the method name + insertText.toString(), + Kind.METHOD, + returnType, + returnTypeInfo, // TypeInfo for return type + method.getSignature(), + method.getDocumentation(), + method, + false, + false, + null, + inheritanceDepth + ); + } + + /** + * Create from a JavaScript JSFieldInfo. + */ + public static AutocompleteItem fromJSField(JSFieldInfo field) { + return fromJSField(field, null, 0); + } + + /** + * Create from a JavaScript JSFieldInfo with inheritance depth. + * @param field The field info + * @param inheritanceDepth Depth in inheritance tree (0 = child, 1 = parent, etc.) + */ + public static AutocompleteItem fromJSField(JSFieldInfo field, int inheritanceDepth) { + return fromJSField(field, null, inheritanceDepth); + } + + /** + * Create from a JavaScript JSFieldInfo with type parameter resolution for display. + * @param field The field info + * @param contextType The TypeInfo context for resolving type parameters + * @param inheritanceDepth Depth in inheritance tree (0 = child, 1 = parent, etc.) + */ + public static AutocompleteItem fromJSField(JSFieldInfo field, TypeInfo contextType, int inheritanceDepth) { + // Get field type using the new method with full generic display + TypeInfo fieldTypeInfo = field.getResolvedType(contextType); + String fieldType = fieldTypeInfo.isResolved() ? fieldTypeInfo.getDisplayName() : field.getType(); + + return new AutocompleteItem( + field.getName(), + field.getName(), // searchName same as display name + field.getName(), + Kind.FIELD, + fieldType, + fieldTypeInfo, // TypeInfo for field type + field.toString(), + field.getDocumentation(), + field, + false, + false, + null, + inheritanceDepth + ); + } + + /** + * Create a keyword item. + */ + public static AutocompleteItem keyword(String keyword) { + return new AutocompleteItem( + keyword, + keyword, // searchName same as display name + keyword, + Kind.KEYWORD, + "keyword", + null, // TypeInfo for keyword + null, + null, + null, + false, + false, + null, + -1 // Keywords don't use inheritance depth sorting + ); + } + + /** + * Create from a synthetic method (e.g., Nashorn built-in like Java.type). + */ + public static AutocompleteItem fromSyntheticMethod( + SyntheticMethod method, + TypeInfo containingType) { + return fromSyntheticMethod(method, containingType, false); + } + + /** + * Create from a synthetic method (e.g., Nashorn built-in like Java.type). + * @param method The synthetic method + * @param containingType The containing type info + * @param forMethodReference If true, insert text will NOT include parentheses + */ + public static AutocompleteItem fromSyntheticMethod( + SyntheticMethod method, + TypeInfo containingType, + boolean forMethodReference) { + String name = method.name; + StringBuilder insertText = new StringBuilder(name); + + // For method references, don't add () since it's ::methodName not ::methodName() + if (!forMethodReference) { + insertText.append("("); + insertText.append(")"); + } + + // Build display name with parameters - use simple names for types + StringBuilder displayName = new StringBuilder(name); + displayName.append("("); + for (int i = 0; i < method.parameters.size(); i++) { + if (i > 0) displayName.append(", "); + SyntheticParameter param = + method.parameters.get(i); + // Use simple name for display (e.g., "Class" instead of "java.lang.Class") + String simpleTypeName = getSimpleTypeName(param.typeName); + displayName.append(simpleTypeName).append(" ").append(param.name); + } + displayName.append(")"); + + // Use simple name for return type display too + String simpleReturnType = getSimpleTypeName(method.returnType); + + return new AutocompleteItem( + displayName.toString(), + name, // Search against just the method name + insertText.toString(), + Kind.METHOD, + simpleReturnType, + method.getReturnTypeInfo(), // TypeInfo for return type - would need to resolve from TypeResolver + method.getSignature(), + method.documentation, + method, + false, + false, + null, + 0 // Synthetic types are at depth 0 + ); + } + + /** + * Get simple name from a fully-qualified type name. + */ + private static String getSimpleTypeName(String typeName) { + if (typeName == null) return "void"; + int lastDot = typeName.lastIndexOf('.'); + return lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName; + } + + /** + * Create from a synthetic field (e.g., Nashorn built-in). + */ + public static AutocompleteItem fromSyntheticField( + SyntheticField field) { + return new AutocompleteItem( + field.name, + field.name, // searchName same as display name + field.name, + Kind.FIELD, + field.typeName, + field.getTypeInfo(), // TypeInfo for field type - would need to resolve from TypeResolver + field.typeName + " " + field.name, + field.documentation, + field, + false, + false, + null, + 0 // Synthetic types are at depth 0 + ); + } + + // ==================== HELPER METHODS ==================== + + private static String buildMethodSignature(MethodInfo method) { + StringBuilder sb = new StringBuilder(); + String returnType = method.getReturnType() != null ? + getName(method.getReturnType()) : "void"; + sb.append(returnType).append(" ").append(method.getName()).append("("); + + for (int i = 0; i < method.getParameterCount(); i++) { + if (i > 0) sb.append(", "); + FieldInfo param = method.getParameters().get(i); + String paramType = param.getTypeInfo() != null ? + getName(param.getTypeInfo()) : "?"; + sb.append(paramType).append(" ").append(param.getName()); + } + + sb.append(")"); + return sb.toString(); + } + + // ==================== MATCHING ==================== + + /** + * Calculate fuzzy match score against a query string. + * Returns -1 if no match, or a positive score (higher = better match). + * + * @param query The search query + * @param requirePrefix If true, only match items that start with the query (no fuzzy/contains matching) + */ + public int calculateMatchScore(String query, boolean requirePrefix) { + if (query == null || query.isEmpty()) { + matchScore = 100; // Everything matches empty query + matchIndices = new int[0]; + return matchScore; + } + + String lowerName = searchName.toLowerCase(); + String lowerQuery = query.toLowerCase(); + + // Exact prefix match is best + if (lowerName.startsWith(lowerQuery)) { + matchScore = 1000 - query.length(); // Shorter prefixes score higher + matchIndices = new int[query.length()]; + for (int i = 0; i < query.length(); i++) { + matchIndices[i] = i; + } + return matchScore; + } + + // If requirePrefix is true, stop here - no fuzzy/substring matching + if (requirePrefix) { + matchScore = -1; + matchIndices = new int[0]; + return matchScore; + } + + // Exact substring match + int subIndex = lowerName.indexOf(lowerQuery); + if (subIndex >= 0) { + matchScore = 500 - subIndex; // Earlier substrings score higher + matchIndices = new int[query.length()]; + for (int i = 0; i < query.length(); i++) { + matchIndices[i] = subIndex + i; + } + return matchScore; + } + + // Fuzzy match - characters must appear in order + int[] indices = new int[query.length()]; + int nameIdx = 0; + int queryIdx = 0; + int gaps = 0; + int consecutiveBonus = 0; + int lastMatchIdx = -2; + + while (queryIdx < query.length() && nameIdx < searchName.length()) { + if (Character.toLowerCase(searchName.charAt(nameIdx)) == lowerQuery.charAt(queryIdx)) { + indices[queryIdx] = nameIdx; + + // Bonus for consecutive matches + if (nameIdx == lastMatchIdx + 1) { + consecutiveBonus += 10; + } + + // Bonus for matching at word boundaries (camelCase) + if (nameIdx == 0 || !Character.isLetterOrDigit(searchName.charAt(nameIdx - 1)) || + (Character.isUpperCase(searchName.charAt(nameIdx)) && nameIdx > 0 && + Character.isLowerCase(searchName.charAt(nameIdx - 1)))) { + consecutiveBonus += 20; + } + + lastMatchIdx = nameIdx; + queryIdx++; + } else { + gaps++; + } + nameIdx++; + } + + if (queryIdx < query.length()) { + // Didn't match all characters + matchScore = -1; + matchIndices = null; + return -1; + } + + matchScore = 100 + consecutiveBonus - gaps; + matchIndices = indices; + return matchScore; + } + + /** + * Backward compatibility overload - defaults to fuzzy matching (no prefix requirement). + */ + public int calculateMatchScore(String query) { + return calculateMatchScore(query, false); + } + + /** + * Add a boost to the match score (e.g., from usage tracking). + */ + public void addScoreBoost(int boost) { + this.matchScore += boost; + } + + // ==================== GETTERS ==================== + + public String getName() { return name; } + public String getSearchName() { return searchName; } + public String getInsertText() { return insertText; } + public Kind getKind() { return kind; } + public String getTypeLabel() { return typeLabel; } + public TypeInfo getTypeInfo() { return typeInfo; } + public String getSignature() { return signature; } + public String getDocumentation() { return documentation; } + public Object getSourceData() { return sourceData; } + public boolean isDeprecated() { return deprecated; } + public boolean requiresImport() { return requiresImport; } + public String getImportPath() { return importPath; } + public int getMatchScore() { return matchScore; } + public int[] getMatchIndices() { return matchIndices; } + + /** + * Get the display name for a type, including generic arguments. + * Uses the unified getDisplayName() method which handles: + * - Simple types: "String" + * - Parameterized types: "List", "Map" + * - Nested generics: "List>" + * - JS types with namespace: "IPlayerEvent.InteractEvent" + */ + public static String getName(TypeInfo type) { + return type.getDisplayName(); + } + + public int getParameterCount() { + if (sourceData instanceof MethodInfo) { + return ((MethodInfo) sourceData).getParameterCount(); + } else if (sourceData instanceof JSMethodInfo) { + return ((JSMethodInfo) sourceData).getParameterCount(); + } else if (sourceData instanceof SyntheticMethod) { + return ((SyntheticMethod) sourceData).parameters.size(); + } + return 0; + } + + /** + * Check if this item represents a static member. + */ + public boolean isStatic() { + if (sourceData instanceof MethodInfo) { + return ((MethodInfo) sourceData).isStatic(); + } else if (sourceData instanceof FieldInfo) { + return ((FieldInfo) sourceData).isStatic(); + } + return false; + } + + /** + * Check if this item represents a final member. + */ + public boolean isFinal() { + if (sourceData instanceof MethodInfo) { + return ((MethodInfo) sourceData).isFinal(); + } else if (sourceData instanceof FieldInfo) { + return ((FieldInfo) sourceData).isFinal(); + } + return false; + } + + /** + * Check if this method is inherited from Object class and NOT overridden. + * These methods should be deprioritized in autocomplete. + */ + public boolean isInheritedObjectMethod() { + if (kind != Kind.METHOD || !(sourceData instanceof MethodInfo)) { + return false; + } + + MethodInfo methodInfo = (MethodInfo) sourceData; + + // For Java reflection methods, check the declaring class + if (methodInfo.getJavaMethod() != null) { + java.lang.reflect.Method javaMethod = methodInfo.getJavaMethod(); + Class declaringClass = javaMethod.getDeclaringClass(); + + // If the method is declared in Object, it's an inherited Object method + // unless the containing type is Object itself + if (declaringClass.getName().equals("java.lang.Object")) { + TypeInfo containingType = methodInfo.getContainingType(); + // If we're showing methods for Object itself, don't treat them as "inherited" + return containingType != null && !containingType.getFullName().equals("java.lang.Object"); + } + } else { + // For script-defined methods, use the original logic + TypeInfo containingType = methodInfo.getContainingType(); + + // Check if declaring class is Object + if (containingType != null && containingType.getFullName().equals("java.lang.Object")) { + return true; + } + + // Check if method overrides from Object (meaning it's overridden, so NOT inherited) + if (methodInfo.isOverride()) { + TypeInfo overridesFrom = methodInfo.getOverridesFrom(); + // If it overrides from Object, it means this class overrode it, so it's NOT just inherited + return false; + } + } + + return false; + } + + /** + * Get icon identifier based on kind. + */ + public String getIconId() { + switch (kind) { + case METHOD: return "m"; + case FIELD: return "f"; + case VARIABLE: return "v"; + case CLASS: return "C"; + case ENUM: return "E"; + case ENUM_CONSTANT: return "e"; + case KEYWORD: return "k"; + case SNIPPET: return "s"; + default: return "?"; + } + } + + /** + * Get color for the kind icon. + */ + public int getIconColor() { + switch (kind) { + case METHOD: return 0xFFB877DB; // Purple for methods + case FIELD: return 0xFF79C0FF; // Blue for fields + case VARIABLE: return 0xFF7EE787; // Green for variables + case CLASS: return 0xFFFFA657; // Orange for classes + case ENUM: return 0xFFFFD866; // Yellow for enums + case ENUM_CONSTANT: return 0xFFFFD866; + case KEYWORD: return 0xFFFF7B72; // Red for keywords + case SNIPPET: return 0xFFCCCCCC; // Gray for snippets + default: return 0xFFCCCCCC; + } + } + + @Override + public int compareTo(AutocompleteItem other) { + // First by match score (descending) - this includes all penalties and boosts + if (this.matchScore != other.matchScore) { + return other.matchScore - this.matchScore; + } + + // Then by kind priority + if (this.kind.getPriority() != other.kind.getPriority()) { + return this.kind.getPriority() - other.kind.getPriority(); + } + + // For JS types, prioritize by inheritance depth (child class members first) + // Only apply if both items have valid inheritance depth (>= 0) + if (this.inheritanceDepth >= 0 && other.inheritanceDepth >= 0) { + if (this.inheritanceDepth != other.inheritanceDepth) { + return this.inheritanceDepth - other.inheritanceDepth; // Lower depth = closer to child = higher priority + } + } + + // Finally alphabetically + return this.name.compareToIgnoreCase(other.name); + } + + @Override + public String toString() { + return name + " (" + kind + ")"; + } + + // ==================== BUILDER ==================== + + /** + * Builder for creating AutocompleteItem instances without source data. + */ + public static class Builder { + private String name; + private String searchName; + private String insertText; + private Kind kind = Kind.FIELD; + private String typeLabel = ""; + private String signature; + private String documentation; + private Object sourceData; + private boolean deprecated = false; + private boolean requiresImport = false; + private String importPath = null; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder searchName(String searchName) { + this.searchName = searchName; + return this; + } + + public Builder insertText(String insertText) { + this.insertText = insertText; + return this; + } + + public Builder kind(Kind kind) { + this.kind = kind; + return this; + } + + public Builder typeLabel(String typeLabel) { + this.typeLabel = typeLabel; + return this; + } + + public Builder signature(String signature) { + this.signature = signature; + return this; + } + + public Builder documentation(String documentation) { + this.documentation = documentation; + return this; + } + + public Builder sourceData(Object sourceData) { + this.sourceData = sourceData; + return this; + } + + public Builder deprecated(boolean deprecated) { + this.deprecated = deprecated; + return this; + } + + public Builder requiresImport(boolean requiresImport) { + this.requiresImport = requiresImport; + return this; + } + + public Builder importPath(String importPath) { + this.importPath = importPath; + return this; + } + + public AutocompleteItem build() { + if (insertText == null) { + insertText = name; + } + return new AutocompleteItem(name, searchName, insertText, kind, typeLabel, + null, signature, documentation, sourceData, deprecated, requiresImport, importPath, -1); + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java new file mode 100644 index 000000000..20cde85cc --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java @@ -0,0 +1,1088 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import noppes.npcs.api.handler.data.IAction; +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import org.lwjgl.input.Mouse; + +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +/** + * Manages autocomplete state and coordinates between providers and UI. + * This is the main entry point for autocomplete functionality. + */ +public class AutocompleteManager { + + // ==================== CONSTANTS ==================== + + /** Characters that trigger autocomplete */ + private static final String TRIGGER_CHARS = "."; + + /** Characters that should close autocomplete */ + private static final String CLOSE_CHARS = ";{}()[]<>,\"'`"; + + /** Minimum characters before showing suggestions (for non-dot triggers) */ + private static final int MIN_PREFIX_LENGTH = 1; + + /** Maximum number of suggestions to show (performance/UX) */ + private static final int MAX_SUGGESTIONS = 150; + + /** Pattern for identifier characters */ + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[a-zA-Z_$][a-zA-Z0-9_$]*"); + + // ==================== STATE ==================== + + private final AutocompleteMenu menu; + private final JavaAutocompleteProvider javaProvider; + private final JSAutocompleteProvider jsProvider; + + private ScriptTextContainer container; + private ScriptDocument document; + + /** Whether autocomplete is currently active */ + private boolean active = false; + + /** The prefix being typed */ + private String currentPrefix = ""; + + /** Start position of the current prefix */ + private int prefixStartPosition = -1; + + /** Whether this was an explicit trigger (Ctrl+Space) */ + private boolean explicitTrigger = false; + + /** The full class name of the current receiver type (for member access tracking) */ + private String currentReceiverFullName = null; + + /** Whether current context is member access */ + private boolean currentIsMemberAccess = false; + + /** Callback for text insertion */ + private InsertCallback insertCallback; + + /** + * Callback interface for inserting autocomplete results. + */ + public interface InsertCallback { + /** + * Insert text, replacing from start to current cursor position. + * @param text Text to insert + * @param startPosition Position to start replacing from + */ + void insertText(String text, int startPosition); + + /** + * Replace text in a specific range. + * @param text Text to insert + * @param startPosition Position to start replacing from + * @param endPosition Position to end replacing at + */ + void replaceTextRange(String text, int startPosition, int endPosition); + + /** + * Add an import statement and sort all imports. + * @param importPath Full path of the class to import (e.g., "net.minecraft.client.Minecraft") + */ + void addImport(String importPath); + + /** + * Get current cursor position. + */ + int getCursorPosition(); + + /** + * Set cursor position. + * @param position New cursor position + */ + void setCursorPosition(int position); + + /** + * Get current document text. + */ + String getText(); + + /** + * Get cursor screen coordinates for menu positioning. + */ + int[] getCursorScreenPosition(); + + /** + * Get viewport dimensions. + */ + int[] getViewportDimensions(); + } + + // ==================== CONSTRUCTOR ==================== + + public AutocompleteManager() { + this.menu = new AutocompleteMenu(); + this.javaProvider = new JavaAutocompleteProvider(); + this.jsProvider = new JSAutocompleteProvider(); + + // Set up menu callback + menu.setCallback(new AutocompleteMenu.AutocompleteCallback() { + @Override + public void onItemSelected(AutocompleteItem item) { + handleItemSelected(item); + } + + @Override + public void onDismiss() { + active = false; + } + }); + } + + // ==================== CONFIGURATION ==================== + + /** + * Set the script container for type resolution. + */ + public void setContainer(ScriptTextContainer container) { + this.container = container; + if (container != null) { + this.document = container.getDocument(); + javaProvider.setDocument(document); + jsProvider.setDocument(document); + } + } + + /** + * Set the callback for text insertion. + */ + public void setInsertCallback(InsertCallback callback) { + this.insertCallback = callback; + } + + // ==================== TRIGGER LOGIC ==================== + + /** + * Called when a character is typed. + * Determines whether to show, update, or hide autocomplete. + */ + public void onCharTyped(char c, String text, int cursorPosition) { + // Check if we should close autocomplete + if (CLOSE_CHARS.indexOf(c) >= 0) { + dismiss(); + return; + } + + // Check if this is a trigger character + if (TRIGGER_CHARS.indexOf(c) >= 0) { + // Trigger after dot + triggerAfterDot(text, cursorPosition); + return; + } + + // Check if this completes a :: (method reference) + if (c == ':') { + // Normalize cursor position - some callers pass pre-insert position + int effectiveCursor = cursorPosition; + if (effectiveCursor >= 0 && effectiveCursor < text.length() && text.charAt(effectiveCursor) == c) { + effectiveCursor = effectiveCursor + 1; + } + + // Check if the previous character is also : + if (effectiveCursor >= 2 && text.charAt(effectiveCursor - 2) == ':') { + triggerAfterMethodReference(text, effectiveCursor); + return; + } + } + + // Check if we're typing an identifier + if (Character.isJavaIdentifierPart(c)) { + // GuiScriptTextArea passes a cursor position captured BEFORE insertion while `text` + // is already the post-insert text. When this happens, cursorPosition points at the + // newly inserted character, not the caret-after-insert. Normalize to a caret position + // for prefix extraction/update. + int effectiveCursor = cursorPosition; + if (effectiveCursor >= 0 && effectiveCursor < text.length() && text.charAt(effectiveCursor) == c) { + effectiveCursor = effectiveCursor + 1; + } + + if (active) { + // Update existing autocomplete + updatePrefix(text, effectiveCursor); + } else if (Character.isJavaIdentifierStart(c)) { + // Check if we're after a dot with whitespace (e.g., "obj. |" where | is cursor) + int dotPos = findDotBeforeWhitespace(text, effectiveCursor - 1); + if (dotPos >= 0) { + // We're typing after a dot (with possible whitespace), trigger member access + triggerAfterDot(text, effectiveCursor); + } else { + // Potentially start new autocomplete + maybeStartAutocomplete(text, effectiveCursor, false); + } + } + return; + } + + // Any other character closes autocomplete + if (active && !Character.isWhitespace(c)) { + dismiss(); + } + } + + /** + * Called when backspace/delete is pressed. + */ + public void onDeleteKey(String text, int cursorPosition) { + if (active) { + updatePrefix(text, cursorPosition); + + // Close if prefix is now empty and not after dot + if (currentPrefix.isEmpty() && !isAfterDot(text, cursorPosition)) { + dismiss(); + } + } + } + + /** + * Called when cursor position changes (e.g., arrow keys). + * Updates the autocomplete prefix based on the new cursor position. + */ + public void onCursorMove(String text, int cursorPosition) { + if (!active) + return; + + // Check if we're still in a valid identifier position + if (cursorPosition < 0 || cursorPosition > text.length()) { + dismiss(); + return; + } + + // Find the word boundaries at the current position + int wordStart = cursorPosition; + while (wordStart > 0 && Character.isJavaIdentifierPart(text.charAt(wordStart - 1))) { + wordStart--; + } + + int wordEnd = cursorPosition; + while (wordEnd < text.length() && Character.isJavaIdentifierPart(text.charAt(wordEnd))) { + wordEnd++; + } + + // Check if we moved out of the current word + if (cursorPosition < prefixStartPosition || cursorPosition > prefixStartPosition + currentPrefix.length()) { + // Check if we're in a different word that we can autocomplete + boolean isMemberAccess = wordStart > 0 && text.charAt(wordStart - 1) == '.'; + + if (isMemberAccess || wordStart < cursorPosition) { + // Update to the new word + prefixStartPosition = wordStart; + updatePrefix(text, cursorPosition); + } else { + // Moved to empty space or invalid position + dismiss(); + } + } else { + // Still in same word, just update the prefix + updatePrefix(text, cursorPosition); + } + } + + /** + * Explicitly trigger autocomplete (Ctrl+Space). + */ + public void triggerExplicit() { + if (insertCallback == null) return; + + String text = insertCallback.getText(); + int cursorPosition = insertCallback.getCursorPosition(); + + explicitTrigger = true; + + // Check if after dot + if (isAfterDot(text, cursorPosition)) { + triggerAfterDot(text, cursorPosition); + } else { + maybeStartAutocomplete(text, cursorPosition, true); + } + } + + /** + * Trigger autocomplete after a dot is typed. + */ + private void triggerAfterDot(String text, int cursorPosition) { + // Normalize cursor semantics. + // Some callers pass a pre-insert cursor index (points at '.') even though the caret + // is visually after the inserted '.' in the current text. + int caretPos = Math.max(0, Math.min(cursorPosition, text.length())); + + int dotPos = -1; + if (caretPos > 0 && caretPos <= text.length() && text.charAt(caretPos - 1) == '.') { + // Caret-after-dot case + dotPos = caretPos - 1; + } else if (caretPos >= 0 && caretPos < text.length() && text.charAt(caretPos) == '.') { + // Pre-insert cursor case: cursorPosition points at the dot itself + dotPos = caretPos; + caretPos = Math.min(caretPos + 1, text.length()); + } else { + // Look backwards skipping whitespace/identifier to find dot + dotPos = findDotBeforeWhitespace(text, caretPos - 1); + } + + if (dotPos < 0) return; + + String receiverExpr = findReceiverExpression(text, dotPos); + String prefix = findCurrentWord(text, caretPos); + + // Find where the prefix actually starts (after dot + any whitespace) + int prefixStart = dotPos + 1; + while (prefixStart < caretPos && Character.isWhitespace(text.charAt(prefixStart))) { + prefixStart++; + } + + prefixStartPosition = prefixStart; + currentPrefix = prefix; + + showSuggestions(text, caretPos, prefix, prefixStartPosition, true, receiverExpr, false); + } + + /** + * Trigger autocomplete after :: is typed (method reference). + * Shows only method suggestions filtered for method references. + */ + private void triggerAfterMethodReference(String text, int cursorPosition) { + int doubleColonPos = cursorPosition - 2; + + // Find the receiver expression before :: + String receiverExpr = findMethodRefReceiverExpression(text, doubleColonPos); + if (receiverExpr == null || receiverExpr.isEmpty()) return; + + // Skip whitespace after :: when establishing prefix start position + int prefixStart = cursorPosition; + while (prefixStart < text.length() && Character.isWhitespace(text.charAt(prefixStart))) { + prefixStart++; + } + + String prefix = ""; + + prefixStartPosition = prefixStart; + currentPrefix = prefix; + + // Show suggestions filtered for methods only + showSuggestions(text, cursorPosition, prefix, prefixStart, true, receiverExpr, true); + } + + /** + * Find the receiver expression before :: for method references. + * Similar to findReceiverExpression but stops at :: + */ + private String findMethodRefReceiverExpression(String text, int doubleColonPos) { + if (text == null || doubleColonPos <= 0) return ""; + + int pos = doubleColonPos - 1; + + // Skip whitespace immediately left of :: + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) pos--; + if (pos < 0) return ""; + + int end = pos + 1; + + // Walk left across a dotted receiver chain + while (pos >= 0) { + char c = text.charAt(pos); + + if (Character.isWhitespace(c)) { + pos--; + continue; + } + + if (c == ')') { + int open = findMatchingBackward(text, pos, '(', ')'); + if (open < 0) break; + pos = open - 1; + continue; + } + + if (c == ']') { + int open = findMatchingBackward(text, pos, '[', ']'); + if (open < 0) break; + pos = open - 1; + continue; + } + + if (Character.isJavaIdentifierPart(c)) { + // Consume identifier + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) pos--; + + // If preceded by a dot, keep going + int checkPos = pos; + while (checkPos >= 0 && Character.isWhitespace(text.charAt(checkPos))) { + checkPos--; + } + if (checkPos >= 0 && text.charAt(checkPos) == '.') { + pos = checkPos - 1; + continue; + } + break; + } + + // Anything else terminates the receiver + break; + } + + int start = pos + 1; + if (start < 0) start = 0; + if (end > text.length()) end = text.length(); + if (start >= end) return ""; + + String expr = text.substring(start, end).replaceAll("\\s+", " ").trim(); + return expr; + } + + /** + * Maybe start autocomplete based on current context. + */ + private void maybeStartAutocomplete(String text, int cursorPosition, boolean force) { + String prefix = findCurrentWord(text, cursorPosition); + int prefixStart = cursorPosition - prefix.length(); + + // Check minimum prefix length (unless forced) + if (!force && prefix.length() < MIN_PREFIX_LENGTH) { + return; + } + + // Don't auto-trigger if we're in the middle of a word + if (!force && prefixStart > 0 && Character.isJavaIdentifierPart(text.charAt(prefixStart - 1))) { + return; + } + + prefixStartPosition = prefixStart; + currentPrefix = prefix; + + showSuggestions(text, cursorPosition, prefix, prefixStart, false, null, false); + } + + /** + * Update the prefix and refresh suggestions. + */ + private void updatePrefix(String text, int cursorPosition) { + // Re-calculate prefix from the original start position + if (prefixStartPosition < 0 || prefixStartPosition > cursorPosition) { + dismiss(); + return; + } + + String newPrefix = text.substring(prefixStartPosition, cursorPosition); + + // Validate prefix (should be valid identifier or empty) + if (!newPrefix.isEmpty() && !isValidPrefix(newPrefix)) { + dismiss(); + return; + } + + currentPrefix = newPrefix; + + // Check if this is a method reference context (after ::) + boolean isMethodReference = isAfterMethodReference(text, prefixStartPosition); + + // Check if after dot (skipping whitespace) + int dotPos = findDotBeforeWhitespace(text, prefixStartPosition - 1); + boolean isMemberAccess = dotPos >= 0 || isMethodReference; + + String receiverExpr = null; + if (isMethodReference) { + // Find :: position + int doubleColonPos = findDoubleColonBefore(text, prefixStartPosition); + if (doubleColonPos >= 0) { + receiverExpr = findMethodRefReceiverExpression(text, doubleColonPos); + } + } else if (isMemberAccess) { + receiverExpr = findReceiverExpression(text, dotPos); + } + + showSuggestions(text, cursorPosition, currentPrefix, prefixStartPosition, + isMemberAccess, receiverExpr, isMethodReference); + } + + /** + * Check if position is after :: (method reference). + */ + private boolean isAfterMethodReference(String text, int pos) { + int checkPos = pos - 1; + // Skip whitespace + while (checkPos >= 0 && Character.isWhitespace(text.charAt(checkPos))) { + checkPos--; + } + // Check for :: + if (checkPos >= 1 && text.charAt(checkPos) == ':' && text.charAt(checkPos - 1) == ':') { + return true; + } + return false; + } + + /** + * Find the position of :: before the given position. + */ + private int findDoubleColonBefore(String text, int pos) { + int checkPos = pos - 1; + // Skip whitespace + while (checkPos >= 0 && Character.isWhitespace(text.charAt(checkPos))) { + checkPos--; + } + // Check for :: + if (checkPos >= 1 && text.charAt(checkPos) == ':' && text.charAt(checkPos - 1) == ':') { + return checkPos - 1; + } + return -1; + } + + // ==================== SUGGESTION LOGIC ==================== + + /** + * Show autocomplete suggestions. + */ + private void showSuggestions(String text, int cursorPosition, String prefix, + int prefixStart, boolean isMemberAccess, String receiverExpr, + boolean methodsOnly) { + if (insertCallback == null || document == null) return; + + // Track context for usage recording when item is selected + currentIsMemberAccess = isMemberAccess; + currentReceiverFullName = null; + + // Resolve receiver type if member access + if (isMemberAccess && receiverExpr != null) { + // Use a position inside the receiver/scope for resolution. + // `prefixStart` can land on end-exclusive boundaries (e.g., right at bodyEnd), + // causing findInnermostScopeAt(prefixStart) to return null. + int dotPos = findDotBeforeWhitespace(text, prefixStart - 1); + int resolvePos = dotPos >= 0 ? dotPos : prefixStart; + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, resolvePos); + if (receiverType != null && receiverType.isResolved()) { + currentReceiverFullName = receiverType.getFullName(); + } + } + + // Build context + int lineNumber = getLineNumber(text, cursorPosition); + String currentLine = getCurrentLine(text, cursorPosition); + int columnPosition = getColumnPosition(text, cursorPosition); + + AutocompleteProvider.Context context = new AutocompleteProvider.Context( + text, cursorPosition, lineNumber, columnPosition, currentLine, + prefix, prefixStart, isMemberAccess, receiverExpr, explicitTrigger, methodsOnly + ); + + // Get suggestions from appropriate provider + AutocompleteProvider provider = document.isJavaScript() ? jsProvider : javaProvider; + + // No need to update variable types - JSAutocompleteProvider now gets them directly from ScriptDocument + + List suggestions = provider.getSuggestions(context); + + // Limit suggestions to prevent overwhelming the UI and improve performance + // Items are already sorted by relevance in the provider + if (suggestions.size() > MAX_SUGGESTIONS) { + // Disabled for now to show all suggestions + // suggestions = suggestions.subList(0, MAX_SUGGESTIONS); + } + + // Show menu + if (suggestions.isEmpty()) { + if (explicitTrigger) { + // Show "No suggestions" message + suggestions.add(new AutocompleteItem.Builder() + .name("No suggestions") + .kind(AutocompleteItem.Kind.SNIPPET) + .typeLabel("") + .build()); + } else { + dismiss(); + return; + } + } + + // Get screen position for menu + int[] screenPos = insertCallback.getCursorScreenPosition(); + int[] viewport = insertCallback.getViewportDimensions(); + + menu.show(screenPos[0], screenPos[1] + 15, suggestions, viewport[0], viewport[1]); + active = true; + explicitTrigger = false; + } + + // ==================== KEY HANDLING ==================== + + /** + * Handle key press. + * @return true if the key was consumed + */ + public boolean keyPressed(int keyCode) { + if (!active || !menu.isVisible()) return false; + + switch (keyCode) { + case 200: // UP + menu.selectPrevious(); + return true; + + case 208: // DOWN + menu.selectNext(); + return true; + + case 28: // ENTER + case 15: // TAB + if (menu.hasItems()) { + menu.confirmSelection(); + return true; + } + return false; + + case 1: // ESCAPE + dismiss(); + return true; + + default: + return false; + } + } + + /** + * Handle mouse click. + * @return true if the click was consumed + */ + public boolean mouseClicked(int mouseX, int mouseY, int button) { + if (!active) return false; + return menu.mouseClicked(mouseX, mouseY, button); + } + + /** + * Handle mouse scroll. + * @return true if the scroll was consumed + */ + public boolean mouseScrolled(int mouseX, int mouseY, int delta) { + if (!active) return false; + return menu.mouseScrolled(mouseX, mouseY, delta); + } + + /** + * Handle mouse release. + * @return true if the release was consumed + */ + public boolean mouseReleased(int mouseX, int mouseY, int button) { + if (!active) + return false; + return menu.mouseReleased(mouseX, mouseY, button); + } + + /** + * Handle mouse drag. + * @return true if the drag was consumed + */ + public boolean mouseDragged(int mouseX, int mouseY) { + if (!active) + return false; + return menu.mouseDragged(mouseX, mouseY); + } + + // ==================== ITEM SELECTION ==================== + + /** + * Handle when an item is selected from the menu. + */ + private void handleItemSelected(AutocompleteItem item) { + if (insertCallback == null || item == null) return; + + // Don't insert "No suggestions" placeholder + if (item.getName().equals("No suggestions")) { + return; + } + + // Record usage for learning + recordUsage(item); + + String insertText = item.getInsertText(); + + // Smart tab completion: replace till next separator + // Find the end position (current word till next separator) + String text = insertCallback.getText(); + int cursorPos = insertCallback.getCursorPosition(); + int endPos = cursorPos; + + // Extend to consume rest of current word + while (endPos < text.length() && Character.isJavaIdentifierPart(text.charAt(endPos))) { + endPos++; + } + + // Also consume following parentheses and their content if present + if (endPos < text.length() && text.charAt(endPos) == '(') { + int parenDepth = 1; + endPos++; // Skip opening paren + while (endPos < text.length() && parenDepth > 0) { + char c = text.charAt(endPos); + if (c == '(') parenDepth++; + else if (c == ')') parenDepth--; + endPos++; + } + } + + // IMPORTANT: Do the text replacement FIRST, then add import + // This prevents position corruption since import adds text at the TOP + insertCallback.replaceTextRange(insertText, prefixStartPosition, endPos); + + // If the inserted text ends with (), move cursor inside only when parameters exist + if (insertText.endsWith("()") && item.getParameterCount() > 0) { + int currentCursor = insertCallback.getCursorPosition(); + insertCallback.setCursorPosition(currentCursor - 1); + } + + // If this item requires an import, add it AFTER the text replacement + if (item.requiresImport() && item.getImportPath() != null) { + insertCallback.addImport(item.getImportPath()); + } + + active = false; + } + + /** + * Record that the user selected an autocomplete item for usage tracking. + */ + private void recordUsage(AutocompleteItem item) { + if (document == null) return; + + UsageTracker tracker = document.isJavaScript() ? + UsageTracker.getJSInstance() : UsageTracker.getJavaInstance(); + + // For member access, use the resolved receiver type + // For standalone items (types, keywords, variables), owner is null + String owner = currentIsMemberAccess ? currentReceiverFullName : null; + + tracker.recordUsage(item, owner); + } + + // ==================== DISMISS ==================== + + /** + * Dismiss autocomplete. + */ + public void dismiss() { + if (active) { + menu.hide(); + active = false; + currentPrefix = ""; + prefixStartPosition = -1; + explicitTrigger = false; + } + } + + // ==================== DRAWING ==================== + + /** + * Draw the autocomplete menu. + */ + public void draw(int mouseX, int mouseY) { + if (active) { + menu.draw(mouseX, mouseY); + + if (Mouse.isButtonDown(0)) + mouseDragged(mouseX, mouseY); + else + mouseReleased(mouseX, mouseY, 0); + } + } + + // ==================== STATE QUERIES ==================== + + public boolean isActive() { + return active; + } + + public boolean isVisible() { + return active && menu.isVisible(); + } + + public AutocompleteMenu getMenu() { + return menu; + } + + // ==================== HELPER METHODS ==================== + + /** + * Find the word being typed at the cursor position. + */ + private String findCurrentWord(String text, int cursorPos) { + if (text == null || cursorPos <= 0) return ""; + + int start = cursorPos; + while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - 1))) { + start--; + } + + return text.substring(start, cursorPos); + } + + /** + * Find the receiver expression before a dot. + * Handles multiline chains and whitespace intelligently. + * + * Stops at statement boundaries: + * - Semicolons (Java) + * - Blank lines or newlines not part of chain continuation + * + * Continues through: + * - Dots at start/end of lines (chain continuation pattern) + * - Whitespace within expressions + */ + private String findReceiverExpression(String text, int dotPos) { + if (text == null || dotPos <= 0) return ""; + if (dotPos >= text.length()) return ""; + + int end = dotPos; + int pos = dotPos - 1; + + // Skip whitespace immediately left of dot + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) pos--; + if (pos < 0) return ""; + + // Walk left across a dotted receiver chain. + while (pos >= 0) { + // Skip excluded regions (comments/strings) if the document knows them + if (document != null && document.isExcluded(pos)) { + int jumped = jumpBeforeExcluded(pos); + if (jumped == pos) { + pos--; // Safety fallback + } else { + pos = jumped; + } + continue; + } + + char c = text.charAt(pos); + if (Character.isWhitespace(c)) { + pos--; + continue; + } + + if (c == ')') { + int open = findMatchingBackward(text, pos, '(', ')'); + if (open < 0) break; + pos = open - 1; + continue; + } + + if (c == ']') { + int open = findMatchingBackward(text, pos, '[', ']'); + if (open < 0) break; + pos = open - 1; + continue; + } + + if (Character.isJavaIdentifierPart(c)) { + // Consume identifier + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) pos--; + + // If preceded by a dot (allowing whitespace/newlines between), keep going. + int checkPos = pos; + while (checkPos >= 0) { + char ch = text.charAt(checkPos); + if (Character.isWhitespace(ch)) { + checkPos--; + continue; + } + // Critical for cases like: "). // comment\n setData(...)" + // The last non-whitespace char before the newline is inside an excluded range, + // so we need to skip excluded ranges when searching for the dot. + if (document != null && document.isExcluded(checkPos)) { + int jumped = jumpBeforeExcluded(checkPos); + if (jumped == checkPos) { + checkPos--; // Safety fallback + } else { + checkPos = jumped; + } + continue; + } + break; + } + if (checkPos >= 0 && text.charAt(checkPos) == '.') { + pos = checkPos - 1; + continue; + } + break; + } + + // Anything else terminates the receiver + break; + } + + int start = pos + 1; + if (start < 0) start = 0; + if (end > text.length()) end = text.length(); + if (start >= end) return ""; + + String expr = text.substring(start, end); + + // Mask comment ranges so comment words don't get parsed as identifiers/segments. + // Keep string literal ranges intact so argument type resolution still works. + if (document != null) { + char[] chars = expr.toCharArray(); + for (int[] range : document.getExcludedRanges()) { + if (range == null || range.length < 2) continue; + if (!isCommentRange(text, range[0])) continue; + + int a = Math.max(range[0], start) - start; + int b = Math.min(range[1], end) - start; + if (b <= 0 || a >= chars.length) continue; + if (a < 0) a = 0; + if (b > chars.length) b = chars.length; + + for (int i = a; i < b; i++) { + char ch = chars[i]; + if (ch != '\n' && ch != '\r') { + chars[i] = ' '; + } + } + } + expr = new String(chars); + } + expr = expr.replaceAll("\\s+", " ").trim(); + return expr; + } + + private boolean isCommentRange(String text, int start) { + if (start < 0 || start + 1 >= text.length()) return false; + if (text.charAt(start) != '/') return false; + char next = text.charAt(start + 1); + return next == '/' || next == '*'; + } + + /** + * Jump to just before the excluded range that contains position, or return position if unknown. + */ + private int jumpBeforeExcluded(int position) { + if (document == null) return position; + for (int[] range : document.getExcludedRanges()) { + if (position >= range[0] && position < range[1]) { + return range[0] - 1; + } + } + return position; + } + + /** + * Find the matching opening delimiter scanning backward from closePos. + * Skips excluded ranges and ignores delimiters inside string literals. + */ + private int findMatchingBackward(String text, int closePos, char openChar, char closeChar) { + int depth = 0; + boolean inString = false; + char stringChar = 0; + + for (int i = closePos; i >= 0; i--) { + char c = text.charAt(i); + + // Handle strings (backward), respecting escapes + if (!inString && (c == '"' || c == '\'')) { + int backslashCount = 0; + for (int j = i - 1; j >= 0 && text.charAt(j) == '\\'; j--) backslashCount++; + if (backslashCount % 2 == 0) { + inString = true; + stringChar = c; + } + } else if (inString && c == stringChar) { + int backslashCount = 0; + for (int j = i - 1; j >= 0 && text.charAt(j) == '\\'; j--) backslashCount++; + if (backslashCount % 2 == 0) { + inString = false; + } + } + + if (inString) continue; + + if (document != null && document.isExcluded(i)) continue; + + if (c == closeChar) depth++; + else if (c == openChar) { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + /** + * Find dot position before cursor, skipping whitespace. + * Returns -1 if no dot found. + */ + private int findDotBeforeWhitespace(String text, int fromPos) { + int pos = fromPos; + // Skip backwards over whitespace + + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) { + pos--; + } + + // Check if we found a dot + if (pos >= 0 && text.charAt(pos) == '.') { + return pos; + } else { + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) + pos--; + + if (pos >= 0 && text.charAt(pos) == '.') + return pos; + } + return -1; + } + + /** + * Check if cursor is after a dot. + */ + private boolean isAfterDot(String text, int cursorPos) { + // Look backwards, skipping any identifier characters + int pos = cursorPos - 1; + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) { + pos--; + } + // Also skip whitespace to check for dot + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) { + pos--; + } + return pos >= 0 && text.charAt(pos) == '.'; + } + + /** + * Check if a prefix is valid (identifier characters only). + */ + private boolean isValidPrefix(String prefix) { + if (prefix.isEmpty()) return true; + if (!Character.isJavaIdentifierStart(prefix.charAt(0))) return false; + for (int i = 1; i < prefix.length(); i++) { + if (!Character.isJavaIdentifierPart(prefix.charAt(i))) return false; + } + return true; + } + + /** + * Get line number (0-indexed) for a position. + */ + private int getLineNumber(String text, int position) { + int line = 0; + for (int i = 0; i < position && i < text.length(); i++) { + if (text.charAt(i) == '\n') line++; + } + return line; + } + + /** + * Get the current line text. + */ + private String getCurrentLine(String text, int position) { + int lineStart = text.lastIndexOf('\n', position - 1) + 1; + int lineEnd = text.indexOf('\n', position); + if (lineEnd < 0) lineEnd = text.length(); + return text.substring(lineStart, lineEnd); + } + + /** + * Get column position within the current line. + */ + private int getColumnPosition(String text, int position) { + int lineStart = text.lastIndexOf('\n', position - 1) + 1; + return position - lineStart; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java new file mode 100644 index 000000000..52b8fd86f --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java @@ -0,0 +1,776 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.ScaledResolution; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * UI component for displaying autocomplete suggestions. + * Styled similar to SearchReplaceBar with IntelliJ-like appearance. + */ +public class AutocompleteMenu extends Gui { + + // ==================== CONSTANTS ==================== + private static final int MAX_VISIBLE_ITEMS = 10; + private static final int ITEM_HEIGHT = 16; + private static final int PADDING = 4; + private static final int ICON_WIDTH = 16; + private static final int MIN_WIDTH = 200; + private static final int MAX_WIDTH = 400; + private static final int HINT_HEIGHT = 18; + + // ==================== COLORS ==================== + private static final int BG_COLOR = 0xFF2d2d30; + private static final int BORDER_COLOR = 0xFF3c3c3c; + private static final int SELECTED_BG = 0xFF094771; + private static final int HOVER_BG = 0xFF37373d; + private static final int TEXT_COLOR = 0xFFe0e0e0; + private static final int DIM_TEXT_COLOR = 0xFF808080; + private static final int HIGHLIGHT_COLOR = 0xFFFFD866; + private static final int HINT_BG_COLOR = 0xFF252526; + private static final int SCROLLBAR_BG = 0xFF3c3c3c; + private static final int SCROLLBAR_FG = 0xFF606060; + + // ==================== STATE ==================== + private boolean visible = false; + private List items = new ArrayList<>(); + private int selectedIndex = 0; + private int scrollOffset = 0; + private int hoveredIndex = -1; + + // Scrollbar drag state + private boolean isDraggingScrollbar = false; + private int dragStartY = 0; + private int dragStartScroll = 0; + + // ==================== POSITION ==================== + private int x, y; + private int width, height; + private int menuWidth; + private int visibleItemsCount; // Actual number of items that fit in the menu + + // ==================== FONT ==================== + private final FontRenderer font; + + // ==================== CALLBACK ==================== + private AutocompleteCallback callback; + + /** + * Callback interface for autocomplete events. + */ + public interface AutocompleteCallback { + void onItemSelected(AutocompleteItem item); + void onDismiss(); + } + + public AutocompleteMenu() { + this.font = Minecraft.getMinecraft().fontRenderer; + } + + public void setCallback(AutocompleteCallback callback) { + this.callback = callback; + } + + // ==================== VISIBILITY ==================== + + /** + * Show the menu at the specified position with the given items. + */ + public void show(int x, int y, List items, int viewportWidth, int viewportHeight) { + this.items = items != null ? new ArrayList<>(items) : new ArrayList<>(); + this.selectedIndex = 0; + this.scrollOffset = 0; + this.hoveredIndex = -1; + + // Calculate menu dimensions + calculateDimensions(x, y, viewportWidth, viewportHeight); + + this.visible = !this.items.isEmpty(); + } + + /** + * Update the items while keeping the menu open. + */ + public void updateItems(List newItems) { + this.items = newItems != null ? new ArrayList<>(newItems) : new ArrayList<>(); + + // Clamp selection + if (selectedIndex >= items.size()) { + selectedIndex = Math.max(0, items.size() - 1); + } + + // Clamp scroll + int maxScroll = Math.max(0, items.size() - MAX_VISIBLE_ITEMS); + if (scrollOffset > maxScroll) { + scrollOffset = maxScroll; + } + + visible = !items.isEmpty(); + } + + /** + * Hide the menu. + */ + public void hide() { + visible = false; + if (callback != null) { + callback.onDismiss(); + } + } + + public boolean isVisible() { + return visible; + } + + public boolean hasItems() { + return !items.isEmpty(); + } + + // ==================== DIMENSIONS ==================== + + private void calculateDimensions(int cursorX, int cursorY, int viewportWidth, int viewportHeight) { + // Calculate width based on item content + int maxItemWidth = MIN_WIDTH; + for (AutocompleteItem item : items) { + int itemWidth = ICON_WIDTH + PADDING + font.getStringWidth(item.getName()) + PADDING; + if (item.getTypeLabel() != null) { + itemWidth += font.getStringWidth(item.getTypeLabel()) + PADDING * 2; + } + maxItemWidth = Math.max(maxItemWidth, itemWidth); + } + menuWidth = Math.min(maxItemWidth + 20, MAX_WIDTH); // +20 for scrollbar + + // Calculate initial height + int visibleItems = Math.min(items.size(), MAX_VISIBLE_ITEMS); + int menuHeight = visibleItems * ITEM_HEIGHT + HINT_HEIGHT + PADDING * 2; + + // Line height estimation (cursor position is typically at baseline) + int lineHeight = 20; + + // Calculate available space + int spaceBelow = viewportHeight - cursorY - 10; // Space below cursor + int spaceAbove = cursorY - lineHeight - 10; // Space above current line + + // Determine if we should use horizontal positioning + // Use horizontal if both vertical positions would be cramped or block the cursor line + boolean useHorizontalPosition = false; + if (spaceBelow < menuHeight && spaceAbove < menuHeight) { + // Check if horizontal positioning would work better + int spaceRight = viewportWidth - (cursorX + 50) - 10; // 50 = offset from cursor + if (spaceRight >= menuWidth && viewportHeight > menuHeight + 20) { + useHorizontalPosition = true; + } else { + // Reduce height to fit in the best available vertical space + int availableVerticalSpace = Math.max(spaceBelow, spaceAbove); + if (availableVerticalSpace < menuHeight) { + // Calculate how many items we can show in available space + int maxItemsForSpace = (availableVerticalSpace - HINT_HEIGHT - PADDING * 2) / ITEM_HEIGHT; + maxItemsForSpace = Math.max(3, Math.min(maxItemsForSpace, items.size())); // At least 3 items + visibleItems = maxItemsForSpace; + menuHeight = visibleItems * ITEM_HEIGHT + HINT_HEIGHT + PADDING * 2; + } + } + } + + // Store the calculated visible items count + this.visibleItemsCount = visibleItems; + + // Position the menu + if (useHorizontalPosition) { + // Position to the right of the cursor + this.x = cursorX + 50; + this.y = cursorY - lineHeight; // Align with current line + + // Ensure doesn't go off bottom + if (y + menuHeight > viewportHeight - 10) { + y = viewportHeight - menuHeight - 10; + } + } else { + // Standard vertical positioning + this.x = cursorX; + + // Try below first + if (spaceBelow >= menuHeight) { + this.y = cursorY ; + } else if (spaceAbove >= menuHeight) { + // Show above cursor line + this.y = cursorY - menuHeight - lineHeight; + } else { + // Use the larger space + if (spaceBelow >= spaceAbove) { + this.y = cursorY; + } else { + this.y = cursorY - menuHeight - lineHeight; + } + } + + // Check if menu would go off-screen to the right + if (x + menuWidth > viewportWidth - 10) { + x = viewportWidth - menuWidth - 10; + } + } + + // Ensure not off-screen to the left or top + x = Math.max(5, x); + y = Math.max(5, y); + + this.width = menuWidth; + this.height = menuHeight; + } + + // ==================== SELECTION ==================== + + /** + * Move selection up. + */ + public void selectPrevious() { + if (items.isEmpty()) return; + + selectedIndex--; + if (selectedIndex < 0) { + selectedIndex = items.size() - 1; + scrollOffset = Math.max(0, items.size() - MAX_VISIBLE_ITEMS); + } else if (selectedIndex < scrollOffset) { + scrollOffset = selectedIndex; + } + } + + /** + * Move selection down. + */ + public void selectNext() { + if (items.isEmpty()) return; + + selectedIndex++; + if (selectedIndex >= items.size()) { + selectedIndex = 0; + scrollOffset = 0; + } else if (selectedIndex >= scrollOffset + MAX_VISIBLE_ITEMS) { + scrollOffset = selectedIndex - MAX_VISIBLE_ITEMS + 1; + } + } + + /** + * Confirm the current selection. + */ + public void confirmSelection() { + if (items.isEmpty() || selectedIndex < 0 || selectedIndex >= items.size()) { + hide(); + return; + } + + AutocompleteItem selected = items.get(selectedIndex); + if (callback != null) { + callback.onItemSelected(selected); + } + hide(); + } + + /** + * Get the currently selected item. + */ + public AutocompleteItem getSelectedItem() { + if (items.isEmpty() || selectedIndex < 0 || selectedIndex >= items.size()) { + return null; + } + return items.get(selectedIndex); + } + + // ==================== DRAWING ==================== + + /** + * Draw the autocomplete menu. + */ + public void draw(int mouseX, int mouseY) { + if (!visible || items.isEmpty()) return; + + // Update hover state + updateHoverState(mouseX, mouseY); + + // Enable scissoring to clip content + GL11.glEnable(GL11.GL_SCISSOR_TEST); + setScissor(x - 2, y - 2, width + 4, height + 4); + + // Draw background + drawRect(x, y, x + width, y + height, BG_COLOR); + + // Draw border + drawRect(x, y, x + width, y + 1, BORDER_COLOR); + drawRect(x, y + height - 1, x + width, y + height, BORDER_COLOR); + drawRect(x, y, x + 1, y + height, BORDER_COLOR); + drawRect(x + width - 1, y, x + width, y + height, BORDER_COLOR); + + // Draw items + int itemY = y + PADDING; + + for (int i = 0; i < visibleItemsCount; i++) { + int itemIndex = scrollOffset + i; + if (itemIndex >= items.size()) break; + + AutocompleteItem item = items.get(itemIndex); + boolean isSelected = (itemIndex == selectedIndex); + boolean isHovered = (itemIndex == hoveredIndex); + + drawItem(item, x + PADDING, itemY, width - PADDING * 2 - 8, isSelected, isHovered); + itemY += ITEM_HEIGHT; + } + + // Draw scrollbar if needed + if (items.size() > visibleItemsCount) { + drawScrollbar(mouseX, mouseY); + } + + // Draw hint bar at bottom + drawHintBar(); + + GL11.glDisable(GL11.GL_SCISSOR_TEST); + } + + /** + * Draw a single autocomplete item. + */ + private void drawItem(AutocompleteItem item, int itemX, int itemY, int itemWidth, + boolean selected, boolean hovered) { + // Background + if (selected) { + drawRect(itemX - 2, itemY, itemX + itemWidth + 2, itemY + ITEM_HEIGHT, SELECTED_BG); + } else if (hovered) { + drawRect(itemX - 2, itemY, itemX + itemWidth + 2, itemY + ITEM_HEIGHT, HOVER_BG); + } + + int textX = itemX; + int textY = itemY + (ITEM_HEIGHT - font.FONT_HEIGHT) / 2; + + // Draw modifier text (static/final) if present + boolean isStatic = item.isStatic(); + boolean isFinal = item.isFinal(); + if (isStatic || isFinal) { + GL11.glPushMatrix(); + float scale = 0.5f; + GL11.glScalef(scale, scale, scale); + int col = TokenType.MODIFIER.getHexColor(); + + if (isStatic) + font.drawString("s", (int) (textX / scale), (int) (textY / scale), col); + if (isFinal) + font.drawString("f", (int) (textX / scale), (int) (textY / scale) + 10, col); + GL11.glPopMatrix(); + } + + // Draw icon + String icon = item.getIconId(); + int iconColor = item.getIconColor(); + font.drawString(icon, textX + (ICON_WIDTH - font.getStringWidth(icon)) / 2, textY, iconColor); + textX += ICON_WIDTH; + + // Calculate available width for text (leave space for type label) + int availableWidth = itemWidth - (textX - itemX); + if (item.getTypeLabel() != null && !item.getTypeLabel().isEmpty()) { + int typeLabelWidth = font.getStringWidth(item.getTypeLabel()); + availableWidth -= typeLabelWidth + PADDING * 2; + } + + // Determine text color - gray for inherited Object methods + int textColor = getColor(item); + if (item.getKind() == AutocompleteItem.Kind.METHOD) { + drawMethodNameTruncated(item.getName(), item.getMatchIndices(), textX, textY, + textColor, availableWidth); + } else { + drawHighlightedTextTruncated(item.getName(), item.getMatchIndices(), textX, textY, + textColor, availableWidth); + } + + // Draw type label on the right + if (item.getTypeLabel() != null && !item.getTypeLabel().isEmpty()) { + String typeLabel = item.getTypeLabel(); + int typeLabelWidth = font.getStringWidth(typeLabel); + int typeLabelX = itemX + itemWidth - typeLabelWidth - PADDING; + + TypeInfo type = item.getTypeInfo(); + int col = type != null ? type.getTokenType().getHexColor() : DIM_TEXT_COLOR; + font.drawString(typeLabel, typeLabelX, textY, col); + } + } + + public int getColor(AutocompleteItem item) { + if (item.isInheritedObjectMethod() || item.isDeprecated()) + return DIM_TEXT_COLOR; + + switch (item.getKind()) { + case METHOD: + return TokenType.METHOD_CALL.getHexColor(); + case FIELD: + return TokenType.GLOBAL_FIELD.getHexColor(); + case ENUM_CONSTANT: + return TokenType.ENUM_CONSTANT.getHexColor(); + case CLASS: + return TokenType.getColor(item.getTypeInfo()); + case VARIABLE: + return TokenType.LOCAL_FIELD.getHexColor(); + case KEYWORD: + return TokenType.KEYWORD.getHexColor(); + default: + return TEXT_COLOR; + } + } + + /** + * Draw method name with parameters colored gray. + */ + private void drawMethodName(String text, int[] matchIndices, int x, int y, int baseColor) { + // Find the opening parenthesis + int parenIndex = text.indexOf('('); + if (parenIndex == -1) { + // No parameters, just draw normally + drawHighlightedText(text, matchIndices, x, y, baseColor); + return; + } + + // Draw method name part with highlighting + String methodName = text.substring(0, parenIndex); + drawHighlightedText(methodName, matchIndices, x, y, baseColor); + + // Draw parameters part in gray (no highlighting) + String params = text.substring(parenIndex); + int paramX = x + font.getStringWidth(methodName); + font.drawString(params, paramX, y, DIM_TEXT_COLOR); + } + + /** + * Draw method name with parameters colored gray, truncated if too long. + */ + private void drawMethodNameTruncated(String text, int[] matchIndices, int x, int y, int baseColor, int maxWidth) { + // Find the opening parenthesis + int parenIndex = text.indexOf('('); + if (parenIndex == -1) { + // No parameters, just draw normally + drawHighlightedTextTruncated(text, matchIndices, x, y, baseColor, maxWidth); + return; + } + + String methodName = text.substring(0, parenIndex); + String params = text.substring(parenIndex); + + int methodNameWidth = font.getStringWidth(methodName); + int paramsWidth = font.getStringWidth(params); + int totalWidth = methodNameWidth + paramsWidth; + + if (totalWidth <= maxWidth) { + // Fits, draw normally + drawHighlightedText(methodName, matchIndices, x, y, baseColor); + int paramX = x + methodNameWidth; + font.drawString(params, paramX, y, DIM_TEXT_COLOR); + } else { + // Need to truncate + String ellipsis = "..."; + int ellipsisWidth = font.getStringWidth(ellipsis); + + // Always show method name, truncate params if needed + if (methodNameWidth + ellipsisWidth < maxWidth) { + drawHighlightedText(methodName, matchIndices, x, y, baseColor); + int paramX = x + methodNameWidth; + + // Truncate parameters + int availableForParams = maxWidth - methodNameWidth - ellipsisWidth; + String truncatedParams = truncateString(params, availableForParams); + font.drawString(truncatedParams + ellipsis, paramX, y, DIM_TEXT_COLOR); + } else { + // Even method name doesn't fit, truncate it too + int availableForMethod = maxWidth - ellipsisWidth; + String truncatedMethod = truncateString(methodName, availableForMethod); + drawHighlightedText(truncatedMethod, matchIndices, x, y, baseColor); + font.drawString(ellipsis, x + font.getStringWidth(truncatedMethod), y, baseColor); + } + } + } + + /** + * Draw text with specific characters highlighted. + */ + private void drawHighlightedText(String text, int[] matchIndices, int x, int y, int baseColor) { + if (matchIndices == null || matchIndices.length == 0) { + font.drawString(text, x, y, baseColor); + return; + } + + // Create a set of highlighted indices for quick lookup + java.util.Set highlightSet = new java.util.HashSet<>(); + for (int idx : matchIndices) { + highlightSet.add(idx); + } + + // Draw character by character + int currentX = x; + for (int i = 0; i < text.length(); i++) { + String ch = String.valueOf(text.charAt(i)); + int color = highlightSet.contains(i) ? HIGHLIGHT_COLOR : baseColor; + font.drawString(ch, currentX, y, color); + currentX += font.getStringWidth(ch); + } + } + + /** + * Draw text with highlighting, truncated if too long. + */ + private void drawHighlightedTextTruncated(String text, int[] matchIndices, int x, int y, int baseColor, int maxWidth) { + int textWidth = font.getStringWidth(text); + + if (textWidth <= maxWidth) { + // Fits, draw normally + drawHighlightedText(text, matchIndices, x, y, baseColor); + } else { + // Truncate with ellipsis + String ellipsis = "..."; + int ellipsisWidth = font.getStringWidth(ellipsis); + String truncated = truncateString(text, maxWidth - ellipsisWidth); + + // Adjust match indices for truncated text + int[] adjustedIndices = null; + if (matchIndices != null) { + java.util.List validIndices = new java.util.ArrayList<>(); + for (int idx : matchIndices) { + if (idx < truncated.length()) { + validIndices.add(idx); + } + } + adjustedIndices = new int[validIndices.size()]; + for (int i = 0; i < validIndices.size(); i++) { + adjustedIndices[i] = validIndices.get(i); + } + } + + drawHighlightedText(truncated, adjustedIndices, x, y, baseColor); + font.drawString(ellipsis, x + font.getStringWidth(truncated), y, baseColor); + } + } + + /** + * Truncate a string to fit within the given width. + */ + private String truncateString(String text, int maxWidth) { + if (text.isEmpty()) return text; + + int width = 0; + for (int i = 0; i < text.length(); i++) { + width += font.getStringWidth(String.valueOf(text.charAt(i))); + if (width > maxWidth) { + return text.substring(0, Math.max(0, i)); + } + } + return text; + } + + /** + * Draw the scrollbar. + */ + private void drawScrollbar(int mouseX, int mouseY) { + int scrollbarX = x + width - 8; + int scrollbarY = y + PADDING; + int scrollbarHeight = MAX_VISIBLE_ITEMS * ITEM_HEIGHT; + + // Background + drawRect(scrollbarX, scrollbarY, scrollbarX + 6, scrollbarY + scrollbarHeight, SCROLLBAR_BG); + + // Thumb + float thumbRatio = (float) MAX_VISIBLE_ITEMS / items.size(); + int thumbHeight = Math.max(20, (int) (scrollbarHeight * thumbRatio)); + float thumbPosRatio = (float) scrollOffset / Math.max(1, items.size() - MAX_VISIBLE_ITEMS); + int thumbY = scrollbarY + (int) ((scrollbarHeight - thumbHeight) * thumbPosRatio); + + // Check if mouse is above the scrollbar thumb + boolean isAboveScrollbar = mouseX >= scrollbarX && mouseX <= scrollbarX + 6 && + mouseY >= thumbY && mouseY < thumbY + thumbHeight; + + int col = isDraggingScrollbar || isAboveScrollbar? 0xFF808080 : SCROLLBAR_FG; + + drawRect(scrollbarX + 1, thumbY, scrollbarX + 5, thumbY + thumbHeight, col); + } + + /** + * Draw the hint bar at the bottom (shows Tab/Enter to confirm). + */ + private void drawHintBar() { + int hintY = y + height - HINT_HEIGHT; + + // Darker background for hint area + drawRect(x + 1, hintY, x + width - 1, y + height - 1, HINT_BG_COLOR); + + // Draw hints + int hintTextY = hintY + (HINT_HEIGHT - font.FONT_HEIGHT) / 2; + int hintX = x + PADDING; + + // Tab hint + drawKeyHint("Tab", hintX, hintTextY); + hintX += font.getStringWidth("Tab") + 8; + font.drawString("or", hintX, hintTextY, DIM_TEXT_COLOR); + hintX += font.getStringWidth("or") + 4; + + // Enter hint + drawKeyHint("Enter", hintX, hintTextY); + hintX += font.getStringWidth("Enter") + 8; + font.drawString("to insert", hintX, hintTextY, DIM_TEXT_COLOR); + } + + /** + * Draw a key hint with a darker background box. + */ + private void drawKeyHint(String key, int x, int y) { + int keyWidth = font.getStringWidth(key); + int boxPadding = 2; + + // Draw box + drawRect(x - boxPadding, y - boxPadding, + x + keyWidth + boxPadding, y + font.FONT_HEIGHT + boxPadding, + 0xFF404040); + + // Draw key text + font.drawString(key, x, y, TEXT_COLOR); + } + + // ==================== MOUSE HANDLING ==================== + + /** + * Update hover state based on mouse position. + */ + private void updateHoverState(int mouseX, int mouseY) { + hoveredIndex = -1; + + if (!isMouseInBounds(mouseX, mouseY)) return; + + int itemY = y + PADDING; + int visibleItems = Math.min(items.size(), MAX_VISIBLE_ITEMS); + + for (int i = 0; i < visibleItems; i++) { + if (mouseY >= itemY && mouseY < itemY + ITEM_HEIGHT) { + hoveredIndex = scrollOffset + i; + break; + } + itemY += ITEM_HEIGHT; + } + } + + /** + * Handle mouse click. + * @return true if click was consumed + */ + public boolean mouseClicked(int mouseX, int mouseY, int button) { + if (!visible) return false; + + // Check if click is outside menu + if (!isMouseInBounds(mouseX, mouseY)) { + hide(); + return false; + } + + // Check if clicking on scrollbar + if (button == 0 && items.size() > MAX_VISIBLE_ITEMS) { + int scrollbarX = x + width - 8; + if (mouseX >= scrollbarX && mouseX <= scrollbarX + 6) { + // Clicked on scrollbar area - start drag + isDraggingScrollbar = true; + dragStartY = mouseY; + dragStartScroll = scrollOffset; + return true; + } + } + + // Check if clicking on an item + if (button == 0 && hoveredIndex >= 0 && hoveredIndex < items.size()) { + selectedIndex = hoveredIndex; + confirmSelection(); + return true; + } + + return true; // Consume click if inside menu + } + + /** + * Handle mouse release. + * @return true if release was consumed + */ + public boolean mouseReleased(int mouseX, int mouseY, int button) { + if (button == 0 && isDraggingScrollbar) { + isDraggingScrollbar = false; + return true; + } + return false; + } + + /** + * Handle mouse drag. + * @return true if drag was consumed + */ + public boolean mouseDragged(int mouseX, int mouseY) { + if (!visible || !isDraggingScrollbar) + return false; + + // Calculate scroll area height + int listHeight = MAX_VISIBLE_ITEMS * ITEM_HEIGHT; + int scrollbarHeight = Math.max(20, (listHeight * MAX_VISIBLE_ITEMS) / items.size()); + int scrollTrackHeight = listHeight - scrollbarHeight; + + // Calculate new scroll offset based on drag + int deltaY = mouseY - dragStartY; + int maxScroll = items.size() - MAX_VISIBLE_ITEMS; + + if (scrollTrackHeight > 0) { + int scrollDelta = (deltaY * maxScroll) / scrollTrackHeight; + scrollOffset = Math.max(0, Math.min(maxScroll, dragStartScroll + scrollDelta)); + } + + return true; + } + + /** + * Handle mouse scroll. + * @return true if scroll was consumed + */ + public boolean mouseScrolled(int mouseX, int mouseY, int delta) { + if (!visible || !isMouseInBounds(mouseX, mouseY)) return false; + + if (items.size() > MAX_VISIBLE_ITEMS) { + if (delta > 0) { + scrollOffset = Math.max(0, scrollOffset - 1); + } else { + scrollOffset = Math.min(items.size() - MAX_VISIBLE_ITEMS, scrollOffset + 1); + } + return true; + } + + return false; + } + + private boolean isMouseInBounds(int mouseX, int mouseY) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + + // ==================== UTILITY ==================== + + private void setScissor(int x, int y, int width, int height) { + Minecraft mc = Minecraft.getMinecraft(); + ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + int scaleFactor = sr.getScaleFactor(); + + int scissorX = x * scaleFactor; + int scissorY = (sr.getScaledHeight() - y - height) * scaleFactor; + int scissorW = width * scaleFactor; + int scissorH = height * scaleFactor; + + GL11.glScissor(scissorX, scissorY, scissorW, scissorH); + } + + // ==================== GETTERS ==================== + + public int getX() { return x; } + public int getY() { return y; } + public int getWidth() { return width; } + public int getHeight() { return height; } + public List getItems() { return items; } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteProvider.java new file mode 100644 index 000000000..a11282d24 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteProvider.java @@ -0,0 +1,74 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import java.util.List; + +/** + * Abstract provider for autocomplete suggestions. + * Implementations handle different contexts (after dot, in identifier, etc.) + */ +public interface AutocompleteProvider { + + /** + * Context information for autocomplete requests. + */ + class Context { + /** Full text of the document */ + public final String text; + /** Current cursor position in the document */ + public final int cursorPosition; + /** The current line number (0-indexed) */ + public final int lineNumber; + /** Position of cursor within the current line */ + public final int columnPosition; + /** The current line text */ + public final String currentLine; + /** The word being typed (prefix before cursor, after last separator) */ + public final String prefix; + /** Start position of the prefix in the document */ + public final int prefixStart; + /** Whether this is after a dot (member access) */ + public final boolean isMemberAccess; + /** Expression before the dot (for member access) */ + public final String receiverExpression; + /** Whether autocomplete was explicitly triggered (CTRL+Space) */ + public final boolean explicitTrigger; + /** Whether to show only methods (for method reference context ::) */ + public final boolean methodsOnly; + + public Context(String text, int cursorPosition, int lineNumber, int columnPosition, + String currentLine, String prefix, int prefixStart, + boolean isMemberAccess, String receiverExpression, boolean explicitTrigger) { + this(text, cursorPosition, lineNumber, columnPosition, currentLine, prefix, prefixStart, + isMemberAccess, receiverExpression, explicitTrigger, false); + } + + public Context(String text, int cursorPosition, int lineNumber, int columnPosition, + String currentLine, String prefix, int prefixStart, + boolean isMemberAccess, String receiverExpression, boolean explicitTrigger, + boolean methodsOnly) { + this.text = text; + this.cursorPosition = cursorPosition; + this.lineNumber = lineNumber; + this.columnPosition = columnPosition; + this.currentLine = currentLine; + this.prefix = prefix; + this.prefixStart = prefixStart; + this.isMemberAccess = isMemberAccess; + this.receiverExpression = receiverExpression; + this.explicitTrigger = explicitTrigger; + this.methodsOnly = methodsOnly; + } + } + + /** + * Get autocomplete suggestions for the given context. + * @param context The context containing cursor position, text, etc. + * @return List of autocomplete items, sorted by relevance + */ + List getSuggestions(Context context); + + /** + * Check if this provider can handle the given context. + */ + boolean canProvide(Context context); +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java new file mode 100644 index 000000000..0cd3f6418 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java @@ -0,0 +1,228 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import noppes.npcs.client.gui.util.script.interpreter.js_parser.*; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.*; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.*; + +/** + * Autocomplete provider for JavaScript/ECMAScript scripts. + * Uses ScriptDocument's unified TypeInfo/TypeResolver system for type resolution. + */ +public class JSAutocompleteProvider extends JavaAutocompleteProvider { + + private final JSTypeRegistry registry; + + public JSAutocompleteProvider() { + this.registry = JSTypeRegistry.getInstance(); + if (!registry.isInitialized()) { + registry.initializeFromResources(); + } + } + + @Override + public boolean canProvide(Context context) { + return document != null && document.isJavaScript(); + } + + + /** + * Add suggestions for member access (after dot). + * Uses ScriptDocument's unified type resolution system. + */ + protected void addMemberSuggestions(Context context, List items) { + String receiverExpr = context.receiverExpression; + if (receiverExpr == null || receiverExpr.isEmpty()) { + return; + } + + // Use ScriptDocument's resolveExpressionType - handles both Java and JS + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, getMemberAccessResolvePosition(context)); + if (receiverType == null || !receiverType.isResolved()) { + return; + } + + // Check for synthetic types first (e.g., Nashorn Java object, custom types) + SyntheticType syntheticType = + document.getTypeResolver().getSyntheticType(receiverType.getSimpleName()); + if (syntheticType != null) { + addSyntheticTypeSuggestions(syntheticType, items, context.methodsOnly); + return; + } + + // If this is a Java type (has a Java class), delegate to parent's Java handling + // This preserves static/instance context, modifiers, and all Java-specific behavior + Class javaClass = receiverType.getJavaClass(); + if (javaClass != null) { + super.addMemberSuggestions(context, items); + return; + } + + // For pure JS types, check JSTypeRegistry + JSTypeInfo jsTypeInfo = receiverType.getJSTypeInfo(); + if (jsTypeInfo != null) { + // Pass both: jsTypeInfo (current type in hierarchy) and receiverType (context for type params) + addMethodsFromType(jsTypeInfo, receiverType, items, new HashSet<>(), context.methodsOnly); + if (!context.methodsOnly) { + addFieldsFromType(jsTypeInfo, receiverType, items, new HashSet<>()); + } + } + } + + /** + * Add autocomplete suggestions for a synthetic type's methods and fields. + * @param syntheticType The synthetic type + * @param items The list to add items to + * @param forMethodReference If true, only add methods with no parentheses in insert text + */ + private void addSyntheticTypeSuggestions(SyntheticType syntheticType, List items, boolean forMethodReference) { + // Add methods + for (SyntheticMethod method : syntheticType.getMethods()) { + AutocompleteItem item = AutocompleteItem.fromSyntheticMethod(method, syntheticType.getTypeInfo(), forMethodReference); + items.add(item); + } + + // Add fields (skip if forMethodReference is true) + if (!forMethodReference) { + for (SyntheticField field : syntheticType.getFields()) { + AutocompleteItem item = AutocompleteItem.fromSyntheticField(field); + items.add(item); + } + } + } + + /** + * Recursively add methods from a type and its parents. + * @param type The current JS type to get methods from (changes as we walk up inheritance) + * @param contextType The original TypeInfo context for resolving type parameters (stays constant) + */ + protected void addMethodsFromType(JSTypeInfo type, TypeInfo contextType, List items, Set added) { + addMethodsFromType(type, contextType, items, added, 0, false); + } + + /** + * Recursively add methods from a type and its parents. + * @param type The current JS type to get methods from (changes as we walk up inheritance) + * @param contextType The original TypeInfo context for resolving type parameters (stays constant) + * @param forMethodReference If true, insert text will NOT include parentheses + */ + protected void addMethodsFromType(JSTypeInfo type, TypeInfo contextType, List items, Set added, boolean forMethodReference) { + addMethodsFromType(type, contextType, items, added, 0, forMethodReference); + } + + /** + * Recursively add methods from a type and its parents with inheritance depth tracking. + * Shows all overloads - one autocomplete item per overload. + * @param type The current JS type in inheritance chain + * @param contextType The original TypeInfo context for resolving type parameters (e.g., IPlayer with T → EntityPlayerMP) + */ + private void addMethodsFromType(JSTypeInfo type, TypeInfo contextType, List items, Set added, int depth) { + addMethodsFromType(type, contextType, items, added, depth, false); + } + + /** + * Recursively add methods from a type and its parents with inheritance depth tracking. + * Shows all overloads - one autocomplete item per overload. + * @param type The current JS type in inheritance chain + * @param contextType The original TypeInfo context for resolving type parameters (e.g., IPlayer with T → EntityPlayerMP) + * @param forMethodReference If true, insert text will NOT include parentheses + */ + private void addMethodsFromType(JSTypeInfo type, TypeInfo contextType, List items, Set added, int depth, boolean forMethodReference) { + // Collect base method names (without $N suffix) that we haven't processed yet + Set baseNames = new HashSet<>(); + for (String key : type.getMethods().keySet()) { + String baseName = key.contains("$") ? key.substring(0, key.indexOf('$')) : key; + if (!added.contains(baseName)) { + baseNames.add(baseName); + } + } + + // For each base method name, add all overloads + for (String baseName : baseNames) { + added.add(baseName); + // Use getMethodOverloads to get all overloads for this method (excluding inherited) + List overloads = new ArrayList<>(); + if (type.getMethods().containsKey(baseName)) { + overloads.add(type.getMethods().get(baseName)); + } + int index = 1; + while (type.getMethods().containsKey(baseName + "$" + index)) { + overloads.add(type.getMethods().get(baseName + "$" + index)); + index++; + } + // Add one autocomplete item per overload + for (JSMethodInfo method : overloads) { + items.add(AutocompleteItem.fromJSMethod(method, contextType, depth, forMethodReference)); + } + } + + // Add from parent type with incremented depth - keep same contextType + if (type.getResolvedParent() != null) { + addMethodsFromType(type.getResolvedParent(), contextType, items, added, depth + 1, forMethodReference); + } + } + + /** + * Recursively add fields from a type and its parents. + * @param type The current JS type to get fields from (changes as we walk up inheritance) + * @param contextType The original TypeInfo context for resolving type parameters (stays constant) + */ + protected void addFieldsFromType(JSTypeInfo type, TypeInfo contextType, List items, Set added) { + addFieldsFromType(type, contextType, items, added, 0); + } + + /** + * Recursively add fields from a type and its parents with inheritance depth tracking. + * @param type The current JS type in inheritance chain + * @param contextType The original TypeInfo context for resolving type parameters + */ + private void addFieldsFromType(JSTypeInfo type, TypeInfo contextType, List items, Set added, int depth) { + for (JSFieldInfo field : type.getFields().values()) { + if (!added.contains(field.getName())) { + added.add(field.getName()); + // Pass contextType (original receiver) for type parameter resolution, not current type + items.add(AutocompleteItem.fromJSField(field, contextType, depth)); + } + } + + // Add from parent type with incremented depth - keep same contextType + if (type.getResolvedParent() != null) { + addFieldsFromType(type.getResolvedParent(), contextType, items, added, depth + 1); + } + } + + protected void addUnimportedClassSuggestions(String prefix, List items) { + // For JavaScript, we typically don't suggest unimported classes + // as imports are handled differently. This can be customized + // if needed to suggest global JS types. + } + + @Override + protected void addLanguageUniqueSuggestions(Context context, List items) { + super.addLanguageUniqueSuggestions(context, items); + + // Add global variables from both global engine objects and document editor/DataScript globals + List globalNames = new ArrayList<>(); + globalNames.addAll(registry.getGlobalEngineObjects().keySet()); + globalNames.addAll(document.getEditorGlobals().keySet()); + + for (String name : globalNames) { + FieldInfo fieldInfo = document.resolveVariable(name, context.cursorPosition); + if (fieldInfo == null || !fieldInfo.isResolved()) + continue; + + items.add(AutocompleteItem.fromField(fieldInfo)); + } + } + + protected UsageTracker getUsageTracker() { + return UsageTracker.getJSInstance(); + } + + public String[] getKeywords() { + return TypeChecker.getJaveScriptKeywords(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java new file mode 100644 index 000000000..209890e16 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java @@ -0,0 +1,547 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +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.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; + +/** + * Autocomplete provider for Java/Groovy scripts. + * Uses ScriptDocument for type resolution and scope analysis. + */ +public class JavaAutocompleteProvider implements AutocompleteProvider { + + protected ScriptDocument document; + + public void setDocument(ScriptDocument document) { + this.document = document; + } + + @Override + public boolean canProvide(Context context) { + return document != null && !document.isJavaScript(); + } + + @Override + public List getSuggestions(Context context) { + List items = new ArrayList<>(); + + if (document == null) { + return items; + } + + // Resolve owner type for usage tracking and static context detection + String ownerFullName = null; + boolean isStaticContext = false; + if (context.isMemberAccess && context.receiverExpression != null) { + int resolvePos = getMemberAccessResolvePosition(context); + TypeInfo receiverType = document.resolveExpressionType( + context.receiverExpression, resolvePos); + if (receiverType != null && receiverType.isResolved()) { + ownerFullName = receiverType.getFullName(); + } + // Check if accessing through a class (static context) vs instance + isStaticContext = isStaticAccess(context.receiverExpression, resolvePos); + } + + if (context.isMemberAccess) { + // Member access: resolve type of receiver and get its members + addMemberSuggestions(context, items); + } else { + // Identifier context: show variables, methods, types in scope + addScopeSuggestions(context, items); + } + + // For method reference context, filter to show only methods (and constructor "new") + if (context.methodsOnly) { + items.removeIf(item -> item.getKind() != AutocompleteItem.Kind.METHOD); + + // Add "new" constructor reference option if the receiver is a class + if (context.receiverExpression != null) { + int resolvePos = getMemberAccessResolvePosition(context); + TypeInfo receiverType = document.resolveExpressionType( + context.receiverExpression, resolvePos); + if (receiverType != null && receiverType.isResolved() && isStaticContext) { + items.add(new AutocompleteItem.Builder() + .name("new") + .insertText("new") + .kind(AutocompleteItem.Kind.KEYWORD) + .typeLabel("constructor") + .signature(receiverType.getSimpleName() + "::new") + .build()); + } + } + } + + // Filter and score by prefix, then apply usage boosts and static penalties + filterAndScore(items, context.prefix, context.isMemberAccess, isStaticContext, ownerFullName); + + // Sort by score + Collections.sort(items); + + return items; + } + + /** + * Add suggestions for member access (after dot). + */ + protected void addMemberSuggestions(Context context, List items) { + String receiverExpr = context.receiverExpression; + if (receiverExpr == null || receiverExpr.isEmpty()) { + return; + } + + // Resolve the type of the receiver expression + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, getMemberAccessResolvePosition(context)); + + if (receiverType == null || !receiverType.isResolved()) { + return; + } + + // Determine if this is a static context (accessing a class type) + boolean isStaticContext = isStaticAccess(receiverExpr, getMemberAccessResolvePosition(context)); + + Class clazz = receiverType.getJavaClass(); + if (clazz == null) { + // Try ScriptTypeInfo + if (receiverType instanceof ScriptTypeInfo) { + addScriptTypeMembers((ScriptTypeInfo) receiverType, items, isStaticContext, context.methodsOnly); + } + return; + } + + // Add methods + Set addedMethods = new HashSet<>(); + for (Method method : clazz.getMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + // Filter by static context + if (isStaticContext && !Modifier.isStatic(method.getModifiers())) { + continue; + } + + String sig = method.getName() + "(" + method.getParameterCount() + ")"; + if (!addedMethods.contains(sig)) { + addedMethods.add(sig); + MethodInfo methodInfo = MethodInfo.fromReflection(method, receiverType); + items.add(AutocompleteItem.fromMethod(methodInfo, context.methodsOnly)); + } + } + } + + // Add fields (skip if methodsOnly is true - but the filtering will be done in getSuggestions) + if (!context.methodsOnly) { + for (Field field : clazz.getFields()) { + if (Modifier.isPublic(field.getModifiers())) { + // Filter by static context + if (isStaticContext && !Modifier.isStatic(field.getModifiers())) { + continue; + } + + FieldInfo fieldInfo = FieldInfo.fromReflection(field, receiverType); + items.add(AutocompleteItem.fromField(fieldInfo)); + } + } + } + + } + + /** + * Pick a stable position for resolving member-access receiver types. + * + * Using `prefixStart` can land on end-exclusive scope boundaries (position == bodyEnd) + * depending on caller cursor semantics and surrounding syntax, causing scope lookups + * to fail. For member access we prefer the dot position (or nearest non-whitespace) + * immediately before prefixStart. + */ + protected int getMemberAccessResolvePosition(Context context) { + if (context == null || context.text == null || context.text.isEmpty()) { + return 0; + } + + int pos = Math.max(0, Math.min(context.prefixStart, context.text.length())); + int i = Math.min(pos - 1, context.text.length() - 1); + + // Skip whitespace backwards + while (i >= 0 && Character.isWhitespace(context.text.charAt(i))) { + i--; + } + + // Prefer the dot index if present + if (i >= 0 && context.text.charAt(i) == '.') { + return i; + } + + // Fallback: use the provided prefixStart (caret context) + return Math.max(0, Math.min(context.prefixStart, context.text.length())); + } + + /** + * Add members from a script-defined type. + */ + protected void addScriptTypeMembers(ScriptTypeInfo scriptType, List items, boolean isStaticContext) { + addScriptTypeMembers(scriptType, items, isStaticContext, false); + } + + /** + * Add members from a script-defined type. + * @param scriptType The script type to get members from + * @param items The list to add items to + * @param isStaticContext Whether we're in a static context + * @param forMethodReference If true, only add methods with no parentheses in insert text + */ + protected void addScriptTypeMembers(ScriptTypeInfo scriptType, List items, boolean isStaticContext, boolean forMethodReference) { + // Add methods (getMethods returns Map>) + for (List overloads : scriptType.getMethods().values()) { + for (MethodInfo method : overloads) { + // Filter by static context + if (isStaticContext && !method.isStatic()) { + continue; + } + items.add(AutocompleteItem.fromMethod(method, forMethodReference)); + } + } + + // Add fields (skip if methodsOnly/forMethodReference is true) + if (!forMethodReference) { + // Add fields (getFields returns Map) + for (FieldInfo field : scriptType.getFields().values()) { + // Filter by static context + if (isStaticContext && !field.isStatic()) { + continue; + } + items.add(AutocompleteItem.fromField(field)); + } + + // Add enum constants (getEnumConstants returns Map) + for (EnumConstantInfo enumConstant : scriptType.getEnumConstants().values()) { + items.add(AutocompleteItem.fromField(enumConstant.getFieldInfo())); + } + } + + // Add parent class members + if (scriptType.hasSuperClass()) { + TypeInfo superType = scriptType.getSuperClass(); + if (superType != null && superType.getJavaClass() != null) { + Class superClazz = superType.getJavaClass(); + Set addedMethods = new HashSet<>(); + for (Method method : superClazz.getMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + String sig = method.getName() + "(" + method.getParameterCount() + ")"; + if (!addedMethods.contains(sig)) { + addedMethods.add(sig); + MethodInfo methodInfo = MethodInfo.fromReflection(method, superType); + items.add(AutocompleteItem.fromMethod(methodInfo, forMethodReference)); + } + } + } + } + } + } + + /** + * Add suggestions based on current scope (not after a dot). + */ + protected void addScopeSuggestions(Context context, List items) { + int pos = context.cursorPosition; + + // Find containing method + MethodInfo containingMethod = document.findContainingMethod(pos); + + // Add local variables + if (containingMethod != null) { + Map locals = document.getLocalsForMethod(containingMethod); + if (locals != null) { + for (FieldInfo local : locals.values()) { + if (local.isVisibleAt(pos)) { + items.add(AutocompleteItem.fromField(local)); + } + } + } + + // Add method parameters + for (FieldInfo param : containingMethod.getParameters()) { + items.add(AutocompleteItem.fromField(param)); + } + } + + // Add global fields + for (FieldInfo globalField : document.getGlobalFields().values()) { + if (globalField.isVisibleAt(pos)) { + items.add(AutocompleteItem.fromField(globalField)); + } + } + + // Add enclosing type fields (find via script types map) + ScriptTypeInfo enclosingType = findEnclosingType(pos); + if (enclosingType != null) { + for (FieldInfo field : enclosingType.getFields().values()) { + if (field.isVisibleAt(pos)) { + items.add(AutocompleteItem.fromField(field)); + } + } + + // Add methods from enclosing type + for (List overloads : enclosingType.getMethods().values()) { + for (MethodInfo method : overloads) { + items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); + } + } + } + + // Add script-defined methods + for (MethodInfo method : document.getAllMethods()) { + items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); + } + + addLanguageUniqueSuggestions(context, items); + + // Add keywords + addKeywords(items); + } + + protected void addLanguageUniqueSuggestions(Context context, List items) { + // Add imported types + for (TypeInfo type : document.getImportedTypes()) { + items.add(AutocompleteItem.fromType(type)); + } + + // Add script-defined types + for (ScriptTypeInfo scriptType : document.getScriptTypesMap().values()) { + items.add(AutocompleteItem.fromType(scriptType)); + } + + // Add unimported classes that match the prefix (for auto-import) + if (context.prefix != null && context.prefix.length() >= 2 && Character.isUpperCase(context.prefix.charAt(0))) { + addUnimportedClassSuggestions(context.prefix, items); + } + } + + + /** + * Add suggestions for unimported classes that match the prefix. + * These will trigger auto-import when selected. + */ + protected void addUnimportedClassSuggestions(String prefix, List items) { + // Get the type resolver + TypeResolver resolver = TypeResolver.getInstance(); + // Find classes matching this prefix (not just exact matches) + List matchingClasses = resolver.findClassesByPrefix(prefix, -1); + + // Track what's already imported to avoid duplicates + Set importedFullNames = new HashSet<>(); + for (TypeInfo imported : document.getImportedTypes()) { + importedFullNames.add(imported.getFullName()); + } + + // Also track simple names already in the list to avoid showing both + // imported and unimported versions of the same class + Set existingSimpleNames = new HashSet<>(); + for (AutocompleteItem item : items) { + if (item.getKind() == AutocompleteItem.Kind.CLASS || + item.getKind() == AutocompleteItem.Kind.ENUM) { + existingSimpleNames.add(item.getName()); + } + } + + for (String fullName : matchingClasses) { + // Skip if already imported + if (importedFullNames.contains(fullName)) { + continue; + } + + TypeInfo type = resolver.resolveFullName(fullName); + if (type != null && type.isResolved()) { + // Skip if a class with this simple name is already in the list + if (existingSimpleNames.contains(type.getSimpleName())) { + continue; + } + + // Create an item that requires import + AutocompleteItem item = new AutocompleteItem.Builder() + .name(type.getSimpleName()) + .insertText(type.getSimpleName()) + .kind(type.getKind() == TypeInfo.Kind.ENUM ? + AutocompleteItem.Kind.ENUM : AutocompleteItem.Kind.CLASS) + .typeLabel(type.getPackageName()) + .signature(type.getFullName()) + .sourceData(type) + .requiresImport(true) + .importPath(fullName) + .build(); + items.add(item); + existingSimpleNames.add(type.getSimpleName()); + } + } + } + + /** + * Add Java keywords. + */ + protected void addKeywords(List items) { + for (String keyword : getKeywords()) { + items.add(AutocompleteItem.keyword(keyword)); + } + } + + public String[] getKeywords() { + return TypeChecker.getJavaKeywords(); + } + + /** + * Find the enclosing script type at a position. + * This is a workaround since findEnclosingScriptType is package-private. + */ + protected ScriptTypeInfo findEnclosingType(int position) { + for (ScriptTypeInfo type : document.getScriptTypesMap().values()) { + if (type.containsPosition(position)) { + return type; + } + } + return null; + } + + /** + * Check if the receiver expression represents static access (class type). + * Returns true if the expression is a TYPE NAME (not a variable). + */ + protected boolean isStaticAccess(String receiverExpr, int position) { + // Use unified static access checker + return TypeResolver.isStaticAccessExpression(receiverExpr, position, document); + } + + protected UsageTracker getUsageTracker() { + return UsageTracker.getJavaInstance(); + } + + /** + * Filter items by prefix, calculate match scores, apply usage boosts, and penalize static members in instance contexts. + */ + protected void filterAndScore(List items, String prefix, + boolean isMemberAccess, boolean isStaticContext, String ownerFullName) { + UsageTracker tracker = getUsageTracker(); + + if (prefix == null || prefix.isEmpty()) { + // No filtering needed, all items get a base score + usage boost + for (AutocompleteItem item : items) { + item.calculateMatchScore("", false); + applyUsageBoost(item, tracker, ownerFullName); + applyStaticPenalty(item, isMemberAccess, isStaticContext); + applyObjectMethodPenalty(item); + applyKeywordPenalty(item, prefix); + } + return; + } + + // For non-member access (first word), require strict prefix matching + // For member access (after dot), allow fuzzy/contains matching + boolean requirePrefix = !isMemberAccess; + + // Filter, score, apply usage boosts, and apply penalties + Iterator iter = items.iterator(); + while (iter.hasNext()) { + AutocompleteItem item = iter.next(); + int score = item.calculateMatchScore(prefix, requirePrefix); + if (score < 0) { + iter.remove(); + } else { + applyUsageBoost(item, tracker, ownerFullName); + applyStaticPenalty(item, isMemberAccess, isStaticContext); + applyObjectMethodPenalty(item); + applyKeywordPenalty(item, prefix); + } + } + } + + /** + * Apply usage-based score boost to an item. + */ + protected void applyUsageBoost(AutocompleteItem item, UsageTracker tracker, String ownerFullName) { + int usageCount = tracker.getUsageCount(item, ownerFullName); + int boost = UsageTracker.calculateUsageBoost(usageCount); + item.addScoreBoost(boost); + } + + /** + * Apply penalty to static members when accessed in a non-static (instance) context. + * This matches IntelliJ's behavior where static members are deprioritized when + * accessing through an instance (e.g., Minecraft.getMinecraft().getMinecraft()). + * However, if the item is a very strong match (exact prefix), don't penalize as much. + */ + protected void applyStaticPenalty(AutocompleteItem item, boolean isMemberAccess, boolean isStaticContext) { + // Only apply penalty in member access contexts (after dot) + if (!isMemberAccess) { + return; + } + + // Only penalize when we're in an instance context (not static) + if (isStaticContext) { + return; + } + + boolean isStatic = item.isStatic(); + + // Apply penalty to static members in instance context + if (isStatic) { + int matchScore = item.getMatchScore(); + // Strong prefix matches (score >= 800) get a lighter penalty + // Weaker matches get pushed down more aggressively + if (matchScore >= 800) { + // Light penalty for exact prefix matches - just deprioritize slightly + item.addScoreBoost(-200); + } else { + // Heavy penalty for fuzzy/substring matches - push to bottom + item.addScoreBoost(-matchScore); + } + } + } + + /** + * Apply penalty to inherited Object methods to push them to bottom. + * Strong matches get lighter penalty, weak matches get pushed all the way down. + */ + protected void applyObjectMethodPenalty(AutocompleteItem item) { + if (!item.isInheritedObjectMethod()) { + return; + } + + int matchScore = item.getMatchScore(); + // Strong prefix matches (score >= 900) get a moderate penalty + // Everything else gets pushed to the very bottom + if (matchScore >= 900) { + // Strong match - moderate penalty to keep it visible but below normal methods + item.addScoreBoost(-500); + } else { + // Weak match - heavy penalty to push to bottom + item.addScoreBoost(-10000); + } + } + + /** + * Push keywords below non-keywords unless user typed a longer prefix. + */ + protected void applyKeywordPenalty(AutocompleteItem item, String prefix) { + if (item.getKind() != AutocompleteItem.Kind.KEYWORD) { + return; + } + + if (prefix == null || prefix.length() < 2) { + item.addScoreBoost(-10000); + } + } + + /** + * Builder class for AutocompleteItem to handle cases without source data. + */ + private static class AutocompleteItemBuilder { + // This would be needed if AutocompleteItem had a builder pattern + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java new file mode 100644 index 000000000..6d23e0c2f --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java @@ -0,0 +1,284 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import noppes.npcs.CustomNpcs; + +import java.io.*; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tracks user autocomplete selections to improve suggestion ranking. + * Usage counts are persisted to JSON and used to boost frequently selected items. + * + * Key format: "ownerFullName|memberName|KIND" + * - ownerFullName: Full class name for members, empty for standalone items + * - memberName: The item name (method, field, type, etc.) + * - KIND: The item kind (METHOD, FIELD, CLASS, ENUM, etc.) + * + * Examples: + * - "net.minecraft.client.Minecraft|getMinecraft|METHOD" + * - "net.minecraft.entity.player.EntityPlayer|inventory|FIELD" + * - "|EntityPlayer|CLASS" (standalone type suggestion) + * - "|if|KEYWORD" + */ +public class UsageTracker { + + private static final String JAVA_FILE = "java_usages.json"; + private static final String JS_FILE = "js_usages.json"; + private static final long SAVE_INTERVAL_MS = 60_000; // Auto-save every 60 seconds + private static final int USAGE_SCORE_MULTIPLIER = 50; // Score boost per usage + private static final int MAX_USAGE_BOOST = 5000; // Cap the boost to prevent runaway scores + + private static UsageTracker javaInstance; + private static UsageTracker jsInstance; + private static boolean initialized = false; + + private final Map usageCounts = new ConcurrentHashMap<>(); + private final File file; + private final AtomicBoolean dirty = new AtomicBoolean(false); + private final AtomicBoolean loaded = new AtomicBoolean(false); + + private static ScheduledExecutorService scheduler; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + // ==================== SINGLETON ACCESS ==================== + + public static synchronized UsageTracker getJavaInstance() { + ensureInitialized(); + if (javaInstance == null) { + javaInstance = new UsageTracker(JAVA_FILE); + javaInstance.load(); + } + return javaInstance; + } + + public static synchronized UsageTracker getJSInstance() { + ensureInitialized(); + if (jsInstance == null) { + jsInstance = new UsageTracker(JS_FILE); + jsInstance.load(); + } + return jsInstance; + } + + /** + * Ensure the tracker system is initialized with auto-save and shutdown hook. + */ + private static void ensureInitialized() { + if (!initialized) { + initialized = true; + initialize(); + + // Add shutdown hook to save on JVM exit + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + shutdown(); + }, "UsageTracker-Shutdown")); + } + } + + /** + * Initialize the auto-save scheduler. Call once on client startup. + */ + public static void initialize() { + if (scheduler == null || scheduler.isShutdown()) { + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "UsageTracker-AutoSave"); + t.setDaemon(true); + return t; + }); + + scheduler.scheduleAtFixedRate(() -> { + saveAllIfDirty(); + }, SAVE_INTERVAL_MS, SAVE_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } + + /** + * Shutdown and save all data. Call on client shutdown. + */ + public static void shutdown() { + saveAllIfDirty(); + if (scheduler != null && !scheduler.isShutdown()) { + scheduler.shutdown(); + } + } + + private static void saveAllIfDirty() { + if (javaInstance != null) { + javaInstance.saveIfDirty(); + } + if (jsInstance != null) { + jsInstance.saveIfDirty(); + } + } + + // ==================== INSTANCE METHODS ==================== + + private UsageTracker(String filename) { + File dir = getDir(); + this.file = new File(dir, filename); + } + + private File getDir() { + File dir = new File(CustomNpcs.Dir, "tracked_usages"); + if (!dir.exists()) { + dir.mkdir(); + } + return dir; + } + + /** + * Record that the user selected an autocomplete item. + * + * @param owner The owner context (full class name for members, null/empty for standalone) + * @param name The item name + * @param kind The item kind + */ + public void recordUsage(String owner, String name, AutocompleteItem.Kind kind) { + String key = buildKey(owner, name, kind); + usageCounts.merge(key, 1, Integer::sum); + dirty.set(true); + } + + /** + * Record usage directly from an AutocompleteItem. + * + * @param item The selected autocomplete item + * @param ownerFullName The full class name of the owner (for member access), or null + */ + public void recordUsage(AutocompleteItem item, String ownerFullName) { + String name = item.getSearchName() != null ? item.getSearchName() : item.getName(); + + // For methods, include parameter count to distinguish overloads + if (item.getKind() == AutocompleteItem.Kind.METHOD) { + int paramCount = item.getParameterCount(); + name = name + "(" + paramCount + ")"; + } + + recordUsage(ownerFullName, name, item.getKind()); + } + + /** + * Get the usage count for an item. + * + * @param owner The owner context + * @param name The item name + * @param kind The item kind + * @return The number of times this item was selected + */ + public int getUsageCount(String owner, String name, AutocompleteItem.Kind kind) { + String key = buildKey(owner, name, kind); + return usageCounts.getOrDefault(key, 0); + } + + /** + * Get usage count directly from an AutocompleteItem. + */ + public int getUsageCount(AutocompleteItem item, String ownerFullName) { + String name = item.getSearchName() != null ? item.getSearchName() : item.getName(); + + // For methods, include parameter count to distinguish overloads + if (item.getKind() == AutocompleteItem.Kind.METHOD) { + int paramCount = item.getParameterCount(); + name = name + "(" + paramCount + ")"; + } + + return getUsageCount(ownerFullName, name, item.getKind()); + } + + /** + * Calculate the score boost based on usage count. + * Uses a logarithmic scale to prevent very frequent items from dominating completely. + * + * @param usageCount The number of times the item was selected + * @return The score boost to add + */ + public static int calculateUsageBoost(int usageCount) { + if (usageCount <= 0) return 0; + + // Logarithmic scaling: log2(count + 1) * multiplier + // This gives diminishing returns for very high counts + int boost = (int) (Math.log(usageCount + 1) / Math.log(2) * USAGE_SCORE_MULTIPLIER); + return Math.min(boost, MAX_USAGE_BOOST); + } + + /** + * Build a unique key for an item. + */ + public static String buildKey(String owner, String name, AutocompleteItem.Kind kind) { + String ownerPart = owner != null ? owner : ""; + String kindPart = kind != null ? kind.name() : "UNKNOWN"; + return ownerPart + "|" + name + "|" + kindPart; + } + + // ==================== PERSISTENCE ==================== + + /** + * Load usage data from file. + */ + public void load() { + if (loaded.get()) return; + + if (!file.exists()) { + loaded.set(true); + return; + } + + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + Type type = new TypeToken>(){}.getType(); + Map data = GSON.fromJson(reader, type); + if (data != null) { + usageCounts.putAll(data); + } + loaded.set(true); + } catch (Exception e) { + System.err.println("[UsageTracker] Failed to load " + file.getName() + ": " + e.getMessage()); + loaded.set(true); // Mark as loaded even on failure to prevent retry loops + } + } + + /** + * Save usage data to file if there are pending changes. + */ + public void saveIfDirty() { + if (!dirty.compareAndSet(true, false)) { + return; // Not dirty, nothing to save + } + save(); + } + + /** + * Force save usage data to file. + */ + public void save() { + try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { + GSON.toJson(usageCounts, writer); + } catch (Exception e) { + System.err.println("[UsageTracker] Failed to save " + file.getName() + ": " + e.getMessage()); + dirty.set(true); // Mark dirty again so we retry later + } + } + + /** + * Clear all usage data (for testing/reset purposes). + */ + public void clear() { + usageCounts.clear(); + dirty.set(true); + } + + /** + * Get the number of tracked items. + */ + public int size() { + return usageCounts.size(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java new file mode 100644 index 000000000..435c8f42e --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java @@ -0,0 +1,323 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +/** + * Utility class for parsing and analyzing code text. + * Provides methods for finding matching braces/parentheses, removing comments/strings, + * and locating keywords in code. + */ +public final class CodeParser { + + private CodeParser() {} // Utility class + + // ==================== Matching Delimiters ==================== + + /** + * Find the closing brace that matches the opening brace at the given position. + * @param text The text to search + * @param openBrace Position of the opening '{' + * @return Position of matching '}' or -1 if not found + */ + public static int findMatchingBrace(String text, int openBrace) { + if (openBrace < 0 || openBrace >= text.length() || text.charAt(openBrace) != '{') { + return -1; + } + int depth = 1; + for (int i = openBrace + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '{') depth++; + else if (c == '}') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + /** + * Find the closing parenthesis that matches the opening parenthesis at the given position. + * @param text The text to search + * @param openParen Position of the opening '(' + * @return Position of matching ')' or -1 if not found + */ + public static int findMatchingParen(String text, int openParen) { + if (openParen < 0 || openParen >= text.length() || text.charAt(openParen) != '(') { + return -1; + } + int depth = 1; + for (int i = openParen + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '(') depth++; + else if (c == ')') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + // ==================== Comment/String Removal ==================== + + /** + * Remove string literals and comments from code for structural analysis. + * This is used when analyzing control flow to avoid false positives from + * keywords inside strings or comments. + * @param code The source code + * @return Code with strings and comments removed + */ + public static String removeStringsAndComments(String code) { + StringBuilder result = new StringBuilder(); + boolean inString = false; + boolean inChar = false; + boolean inLineComment = false; + boolean inBlockComment = false; + + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + char next = (i + 1 < code.length()) ? code.charAt(i + 1) : 0; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + result.append(c); + } + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i++; // skip '/' + } + continue; + } + + if (inString || inChar) { + if (c == '\\' && i + 1 < code.length()) { + i++; // skip escaped character + continue; + } + if ((inString && c == '"') || (inChar && c == '\'')) { + inString = false; + inChar = false; + } + continue; + } + + // Check for start of comments or strings + if (c == '/' && next == '/') { + inLineComment = true; + i++; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + i++; + continue; + } + if (c == '"') { + inString = true; + continue; + } + if (c == '\'') { + inChar = true; + continue; + } + + result.append(c); + } + + return result.toString(); + } + + /** + * Remove only comments from code, keeping strings intact and preserving positions. + * Comments are replaced with spaces to maintain character positions. + * @param code The source code + * @return Code with comments replaced by spaces + */ + public static String removeComments(String code) { + StringBuilder result = new StringBuilder(); + boolean inString = false; + boolean inChar = false; + boolean inLineComment = false; + boolean inBlockComment = false; + + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + char next = (i + 1 < code.length()) ? code.charAt(i + 1) : 0; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + result.append(c); + } else { + result.append(' '); // Preserve position with spaces + } + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + result.append(" "); // Preserve position + i++; + } else { + result.append(c == '\n' ? '\n' : ' '); // Preserve newlines + } + continue; + } + + if (inString) { + result.append(c); + if (c == '\\' && i + 1 < code.length()) { + result.append(code.charAt(++i)); + continue; + } + if (c == '"') inString = false; + continue; + } + + if (inChar) { + result.append(c); + if (c == '\\' && i + 1 < code.length()) { + result.append(code.charAt(++i)); + continue; + } + if (c == '\'') inChar = false; + continue; + } + + // Check for start of comments + if (c == '/' && next == '/') { + inLineComment = true; + result.append(" "); // Preserve position + i++; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + result.append(" "); // Preserve position + i++; + continue; + } + if (c == '"') { inString = true; } + if (c == '\'') { inChar = true; } + + result.append(c); + } + + return result.toString(); + } + + // ==================== Keyword Detection ==================== + + /** + * Check if a keyword exists at the given position in text. + * Verifies the keyword is not part of a larger identifier. + * @param text The text to check + * @param pos Position to check + * @param keyword The keyword to look for + * @return true if the keyword is at this position as a standalone word + */ + public static boolean isKeywordAt(String text, int pos, String keyword) { + if (pos + keyword.length() > text.length()) return false; + if (!text.substring(pos).startsWith(keyword)) return false; + + boolean validBefore = pos == 0 || !Character.isLetterOrDigit(text.charAt(pos - 1)); + boolean validAfter = pos + keyword.length() >= text.length() + || !Character.isLetterOrDigit(text.charAt(pos + keyword.length())); + + return validBefore && validAfter; + } + + /** + * Find the next "return" keyword that is not inside a string literal. + * @param text The text to search + * @param start Position to start searching from + * @return Position of "return" or -1 if not found + */ + public static int findReturnKeyword(String text, int start) { + boolean inString = false; + boolean inChar = false; + + for (int i = start; i < text.length() - 5; i++) { + char c = text.charAt(i); + + if (inString) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '"') inString = false; + continue; + } + if (inChar) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '\'') inChar = false; + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + + // Check for "return" keyword + if (isKeywordAt(text, i, "return")) { + return i; + } + } + return -1; + } + + /** + * Find the semicolon that ends a return statement, handling nested structures. + * @param text The text to search + * @param start Position to start searching (after "return" keyword) + * @return Position of ';' or -1 if not found + */ + public static int findReturnSemicolon(String text, int start) { + int parenDepth = 0; + int braceDepth = 0; + boolean inString = false; + boolean inChar = false; + + for (int i = start; i < text.length(); i++) { + char c = text.charAt(i); + + if (inString) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '"') inString = false; + continue; + } + if (inChar) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '\'') inChar = false; + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '(') { parenDepth++; continue; } + if (c == ')') { parenDepth--; continue; } + if (c == '{') { braceDepth++; continue; } + if (c == '}') { braceDepth--; continue; } + + if (c == ';' && parenDepth == 0 && braceDepth == 0) { + return i; + } + } + return -1; + } + + // ==================== Whitespace Utilities ==================== + + /** + * Skip whitespace characters starting from the given position. + * @param text The text + * @param pos Starting position + * @return Position of first non-whitespace character + */ + public static int skipWhitespace(String text, int pos) { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) { + pos++; + } + return pos; + } +} + diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java new file mode 100644 index 000000000..6416805ba --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java @@ -0,0 +1,215 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +/** + * Analyzes control flow in method bodies to determine if all paths return a value. + * Used for detecting "missing return statement" errors. + */ +public final class ControlFlowAnalyzer { + + private ControlFlowAnalyzer() {} // Utility class + + /** + * Check if the method body has a guaranteed return on all code paths. + */ + public static boolean hasGuaranteedReturn(String body) { + String cleanBody = CodeParser.removeStringsAndComments(body); + + return hasTopLevelReturn(cleanBody) + || hasCompleteIfElseReturn(cleanBody) + || hasTryFinallyReturn(cleanBody) + || hasCompleteSwitchReturn(cleanBody); + } + + /** + * Check for a bare return statement at the method's top level (brace depth 0). + */ + private static boolean hasTopLevelReturn(String body) { + int braceDepth = 0; + int parenDepth = 0; + + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + + if (c == '{') braceDepth++; + else if (c == '}') braceDepth--; + else if (c == '(') parenDepth++; + else if (c == ')') parenDepth--; + + if (braceDepth == 0 && parenDepth == 0 && isKeywordAt(body, i, "return")) { + return true; + } + } + return false; + } + + /** + * Check for complete if-else chains where all branches return. + */ + private static boolean hasCompleteIfElseReturn(String body) { + int braceDepth = 0; + + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + if (c == '{') { braceDepth++; continue; } + if (c == '}') { braceDepth--; continue; } + + if (braceDepth == 0 && isKeywordAt(body, i, "if")) { + if (checkIfElseChainReturns(body, i)) { + return true; + } + } + } + return false; + } + + /** + * Check if an if-else chain starting at the given position guarantees a return. + * Handles: if(true) { return; } - always returns (else unreachable) + * if(false) { } else { return; } - else always executes + */ + private static boolean checkIfElseChainReturns(String body, int ifStart) { + int pos = ifStart; + + while (pos < body.length()) { + if (!body.substring(pos).startsWith("if")) return false; + pos += 2; + + pos = skipWhitespace(body, pos); + if (pos >= body.length() || body.charAt(pos) != '(') return false; + + int condStart = pos; + int condEnd = CodeParser.findMatchingParen(body, pos); + if (condEnd < 0) return false; + + // Check for literal true/false conditions + String condition = body.substring(condStart + 1, condEnd).trim(); + boolean isAlwaysTrue = condition.equals("true"); + boolean isAlwaysFalse = condition.equals("false"); + + pos = skipWhitespace(body, condEnd + 1); + if (pos >= body.length() || body.charAt(pos) != '{') return false; + + int blockEnd = CodeParser.findMatchingBrace(body, pos); + if (blockEnd < 0) return false; + + String ifBlock = body.substring(pos + 1, blockEnd); + boolean ifBlockReturns = hasGuaranteedReturn(ifBlock); + + // if(true) with return means we always return (else is unreachable) + if (isAlwaysTrue && ifBlockReturns) { + return true; + } + + pos = skipWhitespace(body, blockEnd + 1); + + // Must have else clause for guaranteed return + if (!body.substring(pos).startsWith("else")) { + return false; + } + pos += 4; + pos = skipWhitespace(body, pos); + + // else if - continue the chain + if (isKeywordAt(body, pos, "if")) { + continue; + } + + // Plain else block - final branch + if (pos >= body.length() || body.charAt(pos) != '{') return false; + int elseBlockEnd = CodeParser.findMatchingBrace(body, pos); + if (elseBlockEnd < 0) return false; + + String elseBlock = body.substring(pos + 1, elseBlockEnd); + boolean elseBlockReturns = hasGuaranteedReturn(elseBlock); + + // For if-else to guarantee return: both branches must return + // Special case: if(false) means if-block never executes, so only else matters + if (isAlwaysFalse) { + return elseBlockReturns; + } + + // Normal case: both if and else must return + return ifBlockReturns && elseBlockReturns; + } + return false; + } + + /** + * Check for try-finally where finally block returns. + */ + private static boolean hasTryFinallyReturn(String body) { + for (int i = 0; i < body.length(); i++) { + if (!isKeywordAt(body, i, "try")) continue; + + int tryBlockStart = body.indexOf('{', i); + if (tryBlockStart < 0) continue; + + int tryBlockEnd = CodeParser.findMatchingBrace(body, tryBlockStart); + if (tryBlockEnd < 0) continue; + + // Skip catch blocks + int searchPos = skipCatchBlocks(body, tryBlockEnd + 1); + searchPos = skipWhitespace(body, searchPos); + + // Check for finally + if (body.substring(searchPos).startsWith("finally")) { + int finallyBlockStart = body.indexOf('{', searchPos); + if (finallyBlockStart >= 0) { + int finallyBlockEnd = CodeParser.findMatchingBrace(body, finallyBlockStart); + if (finallyBlockEnd >= 0) { + String finallyBlock = body.substring(finallyBlockStart + 1, finallyBlockEnd); + if (hasGuaranteedReturn(finallyBlock)) { + return true; + } + } + } + } + } + return false; + } + + private static int skipCatchBlocks(String body, int start) { + int pos = start; + while (true) { + pos = skipWhitespace(body, pos); + if (pos >= body.length() || !body.substring(pos).startsWith("catch")) break; + + int catchBlockStart = body.indexOf('{', pos); + if (catchBlockStart < 0) break; + + int catchBlockEnd = CodeParser.findMatchingBrace(body, catchBlockStart); + if (catchBlockEnd < 0) break; + + pos = catchBlockEnd + 1; + } + return pos; + } + + /** + * Check for switch with default where all cases return. + * Returns false for now - full implementation is complex. + */ + private static boolean hasCompleteSwitchReturn(String body) { + return false; + } + + // ==================== Utility Methods ==================== + + private static boolean isKeywordAt(String text, int pos, String keyword) { + if (pos + keyword.length() > text.length()) return false; + if (!text.substring(pos).startsWith(keyword)) return false; + + boolean validBefore = pos == 0 || !Character.isLetterOrDigit(text.charAt(pos - 1)); + boolean validAfter = pos + keyword.length() >= text.length() + || !Character.isLetterOrDigit(text.charAt(pos + keyword.length())); + + return validBefore && validAfter; + } + + private static int skipWhitespace(String text, int pos) { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) { + pos++; + } + return pos; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java new file mode 100644 index 000000000..1e3bae054 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java @@ -0,0 +1,292 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.gui.util.script.interpreter.field.AssignmentInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; +import org.lwjgl.opengl.GL11; + +/** + * Helper class for rendering error underlines in script editor lines. + * Handles the repetitive calculation of underline positions and drawing of curly underlines. + */ +public class ErrorUnderlineRenderer { + private static final int ERROR_COLOR = 0xFF5555; + + // Holds the calculation result for an underline + private static class UnderlinePosition { + final int x; + final int width; + + UnderlinePosition(int x, int width) { + this.x = x; + this.width = width; + } + + boolean isValid() { + return width > 0; + } + } + + /** + * Draw error underlines for all validation errors in the document that intersect the given line. + * + * @param doc The script document containing all errors + * @param lineStartX X coordinate where the line starts rendering + * @param baselineY Y coordinate for the underline baseline + * @param lineText The text content of the line + * @param lineStart Global offset where this line starts + * @param lineEnd Global offset where this line ends + */ + public static void drawErrorUnderlines( + ScriptDocument doc, + int lineStartX, int baselineY, + String lineText, int lineStart, int lineEnd) { + + if (doc == null) + return; + + + // Check all method calls in the document + for (MethodCallInfo call : doc.getMethodCalls()) { + // Skip method declarations that were erroneously recorded as calls + boolean isDeclaration = false; + int methodStart = call.getMethodNameStart(); + for (MethodInfo mi : doc.getAllMethods()) { + //!call.isConstructor for enum declarations + if (!call.isConstructor() && mi.getDeclarationOffset() <= methodStart && mi.getBodyStart() >= methodStart) { + isDeclaration = true; + break; + } + } + if (isDeclaration) + continue; + + // Skip if this call doesn't intersect this line + if (call.getCloseParenOffset() < lineStart || call.getOpenParenOffset() > lineEnd) + continue; + + // Handle arg count errors (underline the method name) + if (call.hasArgCountError()) { + int methodEnd = methodStart + call.getMethodName().length(); + drawUnderlineForSpan(methodStart, methodEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, ERROR_COLOR); + } else if (call.hasArgTypeError()) { + // Handle return type mismatch (underline the method name) - currently commented out + // if (call.hasReturnTypeMismatch()) { ... } + + // Handle arg type errors (underline specific arguments) + + for (MethodCallInfo.ArgumentTypeError error : call.getArgumentTypeErrors()) { + MethodCallInfo.Argument arg = error.getArg(); + drawUnderlineForSpan(arg.getStartOffset(), arg.getEndOffset(), + lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR); + } + } else if(call.hasError()){ + // Handle other call errors (underline the whole call) + drawUnderlineForSpan(call.getMethodNameStart(), call.getCloseParenOffset() + 1, + lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR); + } + } + + // Check all field accesses in the document (currently commented out) + // for (FieldAccessInfo access : doc.getFieldAccesses()) { ... } + + // Check all errored assignments in the document + for (AssignmentInfo assign : doc.getAllErroredAssignments()) { + int underlineStart, underlineEnd; + + if (assign.isLhsError()) { + underlineStart = assign.getLhsStart(); + underlineEnd = assign.getLhsEnd(); + } else if (assign.isRhsError()) { + underlineStart = assign.getRhsStart(); + underlineEnd = assign.getRhsEnd(); + } else if (assign.isFullLineError()) { + underlineStart = assign.getStatementStart(); + underlineEnd = assign.getRhsEnd(); + } else { + continue; + } + + drawUnderlineForSpan(underlineStart, underlineEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, ERROR_COLOR); + } + + // Check all method declarations for errors + for (MethodInfo method : doc.getAllMethods()) { + if (!method.isDeclaration() || !method.hasError()) + continue; + + // Handle return statement type errors + if (method.hasReturnStatementErrors()) { + for (MethodInfo.ReturnStatementError returnError : method.getReturnStatementErrors()) { + drawUnderlineForSpan(returnError.getStartOffset(), returnError.getEndOffset(), + lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR); + } + } + + // Handle missing return error (underline the method name) + else if (method.hasMissingReturnError()) { + int methodNameStart = method.getNameOffset(); + int methodNameEnd = methodNameStart + method.getName().length(); + drawUnderlineForSpan(methodNameStart, methodNameEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, ERROR_COLOR); + } + // Handle parameter errors + else if (method.hasParameterErrors()) { + for (MethodInfo.ParameterError paramError : method.getParameterErrors()) { + FieldInfo param = paramError.getParameter(); + if (param == null || param.getDeclarationOffset() < 0) + continue; + + int paramStart = param.getDeclarationOffset(); + int paramEnd = paramStart + param.getName().length(); + drawUnderlineForSpan(paramStart, paramEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, ERROR_COLOR); + } + } + + // Handle all other errors + else if (method.hasError()) { + drawUnderlineForSpan(method.getFullDeclarationOffset(), method.getDeclarationEnd(), lineStartX, + baselineY, lineText, lineStart, lineEnd, ERROR_COLOR); + } + + } + + + for(ScriptTypeInfo types : doc.getScriptTypes()){ + if(!types.hasError()) + continue; + + int typeStart = types.getDeclarationOffset(); + int typeEnd = types.getBodyStart(); + drawUnderlineForSpan(typeStart, typeEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, ERROR_COLOR); + } + } + + /** + * Calculate underline position for a span of text within a line. + * Handles clipping to line boundaries and pixel width calculation. + * + * @param spanStart Global offset where the span starts + * @param spanEnd Global offset where the span ends + * @param lineStartX X coordinate where the line starts rendering + * @param lineText The text content of the line + * @param lineStart Global offset where this line starts + * @param lineEnd Global offset where this line ends + * @return UnderlinePosition with x and width, or null if span doesn't intersect line + */ + private static UnderlinePosition calculateUnderlinePosition( + int spanStart, int spanEnd, + int lineStartX, String lineText, int lineStart, int lineEnd) { + + // Skip if span doesn't intersect this line + if (spanEnd < lineStart || spanStart > lineEnd) + return null; + + // Clip to line boundaries + int clipStart = Math.max(spanStart, lineStart); + int clipEnd = Math.min(spanEnd, lineEnd); + + if (clipStart >= clipEnd) + return null; + + // Convert to line-local coordinates + int lineLocalStart = clipStart - lineStart; + int lineLocalEnd = clipEnd - lineStart; + + // Bounds check + if (lineLocalStart < 0 || lineLocalStart >= lineText.length()) + return null; + + // Compute pixel position + String beforeSpan = lineText.substring(0, lineLocalStart); + int beforeWidth = ClientProxy.Font.width(beforeSpan); + + int spanWidth; + if (lineLocalEnd > lineText.length()) { + // Span extends past line end + spanWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + } else { + // Span is fully on this line (or clipped) + String spanTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); + spanWidth = ClientProxy.Font.width(spanTextOnLine); + } + + return new UnderlinePosition(lineStartX + beforeWidth, spanWidth); + } + + /** + * Draw an underline for a simple span if it intersects the line. + * + * @param spanStart Global offset where the span starts + * @param spanEnd Global offset where the span ends + * @param lineStartX X coordinate where the line starts rendering + * @param baselineY Y coordinate for the underline baseline + * @param lineText The text content of the line + * @param lineStart Global offset where this line starts + * @param lineEnd Global offset where this line ends + * @param color Underline color + */ + public static void drawUnderlineForSpan( + int spanStart, int spanEnd, + int lineStartX, int baselineY, + String lineText, int lineStart, int lineEnd, + int color) { + + UnderlinePosition pos = calculateUnderlinePosition( + spanStart, spanEnd, lineStartX, lineText, lineStart, lineEnd); + + if (pos != null && pos.isValid()) { + drawCurlyUnderline(pos.x, baselineY, pos.width, color); + } + } + + /** + * Draw a curly/wavy underline (like IDE error highlighting). + * @param x Start X position + * @param y Y position (bottom of text) + * @param width Width of the underline + * @param color Color in ARGB format (e.g., 0xFFFF5555 for red) + */ + public static void drawCurlyUnderline(int x, int y, int width, int color) { + if (width <= 0) + return; + + float a = ((color >> 24) & 0xFF) / 255f; + float r = ((color >> 16) & 0xFF) / 255f; + float g = ((color >> 8) & 0xFF) / 255f; + float b = (color & 0xFF) / 255f; + + // If alpha is 0, assume full opacity + if (a == 0) + a = 1.0f; + + GL11.glPushMatrix(); + GL11.glDisable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glColor4f(r, g, b, a); + GL11.glLineWidth(1.0f); + + GL11.glBegin(GL11.GL_LINE_STRIP); + // Wave parameters: 2 pixels amplitude, 4 pixels wavelength + int waveHeight = 1; + float waveLength = 4f; + for (float i = -0.5f; i <= width - 1; i += 0.125f) { + // Create a sine-like wave pattern + double phase = (double) i / waveLength * Math.PI * 2; + float yOffset = (float) (Math.sin(phase) * waveHeight) - 0.25f; + GL11.glVertex2f(x + i + 2f, y + yOffset); + } + GL11.glEnd(); + + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glPopMatrix(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java new file mode 100644 index 000000000..a94d81bc8 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java @@ -0,0 +1,486 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenErrorMessage; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper class for marking chained field accesses in script documents. + * Handles patterns like: identifier.field, this.field, super.field, method().field + */ +public class FieldChainMarker { + private final ScriptDocument document; + private final String text; + + public FieldChainMarker(ScriptDocument document, String text) { + this.document = document; + this.text = text; + } + + /** + * Mark all chained field accesses in the document. + * Uses two passes: one for standard chains, one for method/array result chains. + */ + public void markChainedFieldAccesses(List marks) { + // ==================== PASS 1: Standard identifier chains ==================== + // Pattern: identifier.identifier, this.identifier, super.identifier + Pattern chainPattern = Pattern.compile( + "\\b(this|super|[a-zA-Z_][a-zA-Z0-9_]*)\\s*\\.\\s*([a-zA-Z_][a-zA-Z0-9_]*)"); + Matcher m = chainPattern.matcher(text); + + while (m.find()) { + int chainStart = m.start(1); + + // Skip excluded ranges and imports + if (document.isExcluded(chainStart) || document.isInImportOrPackage(chainStart)) continue; + + // Skip if second segment is a method call - markMethodCalls handles it + if (document.isFollowedByParen(m.end(2))) continue; + + // Build the full chain + ChainData chain = buildChain(m); + if (chain == null) continue; + + // Resolve the starting type + ChainContext ctx = resolveChainStart(chain, chainStart); + + // Mark each segment + for (int i = ctx.startIndex; i < chain.segments.size(); i++) { + ctx.currentIndex = i; + int[] pos = chain.positions.get(i); + + if (document.isExcluded(pos[0])) continue; + + // Determine what type of marking this segment needs + MarkResult result = resolveSegmentMark(ctx, chainStart); + + // Apply the mark + if (result.mark != null) { + marks.add(result.mark); + } + + // Update context for next segment + ctx.currentType = result.nextType; + } + } + + // ==================== PASS 2: Method/array result chains ==================== + // Handle: getMinecraft().thePlayer, array[0].length + Pattern dotIdent = Pattern.compile("\\.\\s*([a-zA-Z_][a-zA-Z0-9_]*)"); + Matcher md = dotIdent.matcher(text); + + while (md.find()) { + int identStart = md.start(1); + int identEnd = md.end(1); + int dotPos = md.start(); + + // Skip excluded ranges and imports + if (document.isExcluded(identStart) || document.isInImportOrPackage(identStart)) continue; + + // Only handle when preceded by ')' or ']' (method call or array access result) + Character precedingChar = getNonWhitespaceBefore(dotPos); + if (precedingChar == null || (precedingChar != ')' && precedingChar != ']')) continue; + + // Get receiver type from the expression before the dot + int[] bounds = document.findReceiverBoundsBefore(dotPos); + if (bounds == null) continue; + + if (document.isExcluded(bounds[0]) || document.isInImportOrPackage(bounds[0])) continue; + + String receiverExpr = text.substring(bounds[0], bounds[1]).trim(); + if (receiverExpr.isEmpty()) continue; + + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, bounds[0]); + + // Mark the first field and continue the chain + markReceiverChainSegments(marks, md.group(1), identStart, identEnd, receiverType); + } + } + + // ==================== CHAIN BUILDING HELPERS ==================== + + /** Data class for a parsed chain */ + private static class ChainData { + final List segments = new ArrayList<>(); + final List positions = new ArrayList<>(); + } + + /** Context for chain resolution */ + private static class ChainContext { + ChainData chain; + int currentIndex; + TypeInfo currentType; + ScriptTypeInfo enclosingType; // The enclosing script type for 'this' resolution + int startIndex; + boolean firstIsThis; + boolean firstIsSuper; + boolean firstIsPrecededByDot; + } + + /** Result of resolving a segment's mark */ + private static class MarkResult { + final ScriptLine.Mark mark; + final TypeInfo nextType; + + MarkResult(ScriptLine.Mark mark, TypeInfo nextType) { + this.mark = mark; + this.nextType = nextType; + } + } + + /** Build a complete chain from a regex match, continuing to read more segments */ + private ChainData buildChain(Matcher m) { + ChainData chain = new ChainData(); + + // Add first two segments from the match + chain.segments.add(m.group(1)); + chain.positions.add(new int[]{m.start(1), m.end(1)}); + chain.segments.add(m.group(2)); + chain.positions.add(new int[]{m.start(2), m.end(2)}); + + // Continue reading subsequent .identifier segments + int pos = m.end(2); + while (pos < text.length()) { + pos = document.skipWhitespace(pos); + if (pos >= text.length() || text.charAt(pos) != '.') break; + pos++; // Skip dot + + pos = document.skipWhitespace(pos); + if (pos >= text.length() || !Character.isJavaIdentifierStart(text.charAt(pos))) break; + + // Read identifier + int identStart = pos; + while (pos < text.length() && Character.isJavaIdentifierPart(text.charAt(pos))) pos++; + int identEnd = pos; + + // Stop if this is a method call + if (document.isFollowedByParen(identEnd)) break; + + chain.segments.add(text.substring(identStart, identEnd)); + chain.positions.add(new int[]{identStart, identEnd}); + } + + return chain; + } + + /** Resolve the starting context for a chain */ + private ChainContext resolveChainStart(ChainData chain, int chainStart) { + ChainContext ctx = new ChainContext(); + ctx.chain = chain; + ctx.currentIndex = 0; + String first = chain.segments.get(0); + + ctx.firstIsThis = first.equals("this"); + ctx.firstIsSuper = first.equals("super"); + ctx.firstIsPrecededByDot = document.isPrecededByDot(chainStart); + ctx.startIndex = ctx.firstIsPrecededByDot ? 0 : 1; + ctx.currentType = null; + ctx.enclosingType = document.findEnclosingScriptType(chainStart); + + if (ctx.firstIsThis) { + // 'this' - resolve to enclosing class/global fields + ctx.currentType = ctx.enclosingType; + } else if (ctx.firstIsSuper) { + // 'super' - resolve to parent class + if (ctx.enclosingType != null && ctx.enclosingType.hasSuperClass()) { + ctx.currentType = ctx.enclosingType.getSuperClass(); + } + } else if (ctx.firstIsPrecededByDot) { + // Field access on a receiver (e.g., getMinecraft().thePlayer) + TypeInfo receiverType = document.resolveReceiverChain(chainStart); + if (receiverType != null && receiverType.hasField(first)) { + FieldInfo varInfo = receiverType.getFieldInfo(first); + ctx.currentType = (varInfo != null) ? varInfo.getTypeInfo() : null; + } + } else { + // Try as type first (static access), then as variable + TypeInfo typeCheck = document.resolveType(first); + if (typeCheck != null && typeCheck.isResolved()) { + ctx.currentType = typeCheck; + } else { + FieldInfo varInfo = document.resolveVariable(first, chainStart); + ctx.currentType = (varInfo != null) ? varInfo.getTypeInfo() : null; + } + } + + return ctx; + } + + /** Resolve what mark a segment should get */ + private MarkResult resolveSegmentMark(ChainContext ctx, int chainStart) { + int index = ctx.currentIndex; + String segment = ctx.chain.segments.get(index); + int[] pos = ctx.chain.positions.get(index); + + // Case 1: First segment preceded by dot (field on receiver) + if (index == 0 && ctx.firstIsPrecededByDot) { + return resolveReceiverFieldMark(ctx, chainStart); + } + + // Case 2: this.field + if (index == 1 && ctx.firstIsThis) { + return resolveThisFieldMark(ctx); + } + + // Case 3: super.field + if (index == 1 && ctx.firstIsSuper) { + return resolveSuperFieldMark(ctx); + } + + // Case 4: Resolved type with field + if (ctx.currentType != null && ctx.currentType.isResolved()) { + return resolveTypedFieldMark(ctx); + } + + // Case 5: Unresolved - mark as undefined + return new MarkResult( + new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR), + null + ); + } + + // ==================== SEGMENT RESOLUTION HELPERS ==================== + + /** Resolve mark for field access on a receiver (preceded by dot) */ + private MarkResult resolveReceiverFieldMark(ChainContext ctx, int chainStart) { + int index = ctx.currentIndex; + String segment = ctx.chain.segments.get(index); + int[] pos = ctx.chain.positions.get(index); + boolean isLast = (index == ctx.chain.segments.size() - 1); + boolean isStatic = isStaticContext(ctx); + + TypeInfo receiverType = document.resolveReceiverChain(chainStart); + + if (receiverType != null && receiverType.hasField(segment)) { + FieldInfo fieldInfo = receiverType.getFieldInfo(segment); + FieldAccessInfo accessInfo = document.createFieldAccessInfo(segment, pos[0], pos[1], + receiverType, fieldInfo, isLast, isStatic); + + return new MarkResult(new ScriptLine.Mark(pos[0], pos[1], getFieldTokenType(fieldInfo), accessInfo), + (fieldInfo != null) ? fieldInfo.getTypeInfo() : null + ); + } + + return new MarkResult( + new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR), + null + ); + } + + /** Resolve mark for this.field access */ + private MarkResult resolveThisFieldMark(ChainContext ctx) { + int index = ctx.currentIndex; + String segment = ctx.chain.segments.get(index); + int[] pos = ctx.chain.positions.get(index); + boolean isLast = (index == ctx.chain.segments.size() - 1); + + boolean found = false; + FieldInfo fieldInfo = null; + + // First check enclosing script type fields + if (ctx.enclosingType != null && ctx.enclosingType.hasField(segment)) { + found = true; + fieldInfo = ctx.enclosingType.getFieldInfo(segment); + } else if (document.getGlobalFields().containsKey(segment)) { + found = true; + fieldInfo = document.getGlobalFields().get(segment); + } + + if (found) { + FieldAccessInfo accessInfo = document.createFieldAccessInfo(segment, pos[0], pos[1], ctx.enclosingType, + fieldInfo, isLast, false); + + return new MarkResult(new ScriptLine.Mark(pos[0], pos[1], getFieldTokenType(fieldInfo), accessInfo), + (fieldInfo != null) ? fieldInfo.getTypeInfo() : null + ); + } + + return new MarkResult(new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR), null + ); + } + + /** Resolve mark for super.field access */ + private MarkResult resolveSuperFieldMark(ChainContext ctx) { + int index = ctx.currentIndex; + String segment = ctx.chain.segments.get(index); + int[] pos = ctx.chain.positions.get(index); + boolean isLast = (index == ctx.chain.segments.size() - 1); + TypeInfo superType = ctx.currentType; + + if (superType == null) { + return new MarkResult( + new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR, + TokenErrorMessage.from("Cannot resolve field '" + segment + "'").clearOtherErrors()), + null + ); + } + + // Search through inheritance hierarchy + boolean found = false; + FieldInfo fieldInfo = null; + + if (superType instanceof ScriptTypeInfo) { + ScriptTypeInfo scriptSuper = (ScriptTypeInfo) superType; + found = scriptSuper.hasFieldInHierarchy(segment); + if (found) fieldInfo = scriptSuper.getFieldInfoInHierarchy(segment); + } else { + found = superType.hasField(segment); + if (found) fieldInfo = superType.getFieldInfo(segment); + } + + if (found) { + FieldAccessInfo accessInfo = document.createFieldAccessInfo(segment, pos[0], pos[1], + superType, fieldInfo, isLast, false); + return new MarkResult(new ScriptLine.Mark(pos[0], pos[1], getFieldTokenType(fieldInfo), accessInfo), + (fieldInfo != null) ? fieldInfo.getTypeInfo() : null + ); + } + + String errorMsg = "Field '" + segment + "' not found in parent class hierarchy starting from '" + + superType.getSimpleName() + "'"; + return new MarkResult( + new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR, + TokenErrorMessage.from(errorMsg).clearOtherErrors()), + null + ); + } + + /** Resolve mark for typed field access */ + private MarkResult resolveTypedFieldMark(ChainContext ctx) { + int index = ctx.currentIndex; + String segment = ctx.chain.segments.get(index); + int[] pos = ctx.chain.positions.get(index); + boolean isLast = (index == ctx.chain.segments.size() - 1); + boolean isStatic = isStaticContext(ctx); + TypeInfo currentType = ctx.currentType; + + if (!currentType.hasField(segment)) { + return new MarkResult( + new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR), + null + ); + } + + FieldInfo fieldInfo = currentType.getFieldInfo(segment); + + // Check for static access error + if (isStatic && fieldInfo != null && !fieldInfo.isStatic()) { + TokenErrorMessage errorMsg = TokenErrorMessage + .from("Cannot access non-static field '" + segment + "' from static context '" + + currentType.getSimpleName() + "'") + .clearOtherErrors(); + return new MarkResult( + new ScriptLine.Mark(pos[0], pos[1], TokenType.UNDEFINED_VAR, errorMsg), + null + ); + } + + // Valid field access + FieldAccessInfo accessInfo = document.createFieldAccessInfo(segment, pos[0], pos[1], + currentType, fieldInfo, isLast, isStatic); + + return new MarkResult(new ScriptLine.Mark(pos[0], pos[1], getFieldTokenType(fieldInfo), accessInfo), + (fieldInfo != null) ? fieldInfo.getTypeInfo() : null + ); + } + + /** Mark segments following a method call or array access result */ + private void markReceiverChainSegments(List marks, String firstField, + int identStart, int identEnd, TypeInfo receiverType) { + // Mark the first field + FieldInfo fInfo = null; + TypeInfo currentType = null; + + if (receiverType != null && receiverType.hasField(firstField)) { + fInfo = receiverType.getFieldInfo(firstField); + boolean hasMore = document.isFollowedByDot(identEnd); + // For method/array result chains, the receiver is always an instance, not a type + boolean isStatic = false; + + FieldAccessInfo accessInfo = document.createFieldAccessInfo(firstField, identStart, identEnd, + receiverType, fInfo, !hasMore, isStatic); + + marks.add(new ScriptLine.Mark(identStart, identEnd, getFieldTokenType(fInfo), accessInfo)); + currentType = (fInfo != null) ? fInfo.getTypeInfo() : null; + } else { + marks.add(new ScriptLine.Mark(identStart, identEnd, TokenType.UNDEFINED_VAR)); + return; + } + + // Continue the chain + int pos = identEnd; + while (pos < text.length()) { + pos = document.skipWhitespace(pos); + if (pos >= text.length() || text.charAt(pos) != '.') break; + pos++; // Skip dot + + pos = document.skipWhitespace(pos); + if (pos >= text.length() || !Character.isJavaIdentifierStart(text.charAt(pos))) break; + + // Read identifier + int nStart = pos; + while (pos < text.length() && Character.isJavaIdentifierPart(text.charAt(pos))) pos++; + int nEnd = pos; + String seg = text.substring(nStart, nEnd); + + // Stop if method call + if (document.isFollowedByParen(nEnd)) break; + if (document.isExcluded(nStart)) break; + + boolean isLast = !document.isFollowedByDot(nEnd); + + if (currentType != null && currentType.isResolved() && currentType.hasField(seg)) { + FieldInfo segInfo = currentType.getFieldInfo(seg); + // In method/array result chains, segments are always instance access + boolean isStatic = false; + + FieldAccessInfo accessInfo = document.createFieldAccessInfo(seg, nStart, nEnd, + currentType, segInfo, isLast, isStatic); + + marks.add(new ScriptLine.Mark(nStart, nEnd, getFieldTokenType(segInfo), accessInfo)); + currentType = (segInfo != null) ? segInfo.getTypeInfo() : null; + } else { + marks.add(new ScriptLine.Mark(nStart, nEnd, TokenType.UNDEFINED_VAR)); + break; + } + } + } + + // ==================== UTILITY HELPERS ==================== + + private TokenType getFieldTokenType(FieldInfo fieldInfo) { + if (fieldInfo != null && fieldInfo.isEnumConstant()) + return TokenType.ENUM_CONSTANT; + + return TokenType.GLOBAL_FIELD; + } + + /** Get the non-whitespace character before a position */ + private Character getNonWhitespaceBefore(int pos) { + int before = pos - 1; + while (before >= 0 && Character.isWhitespace(text.charAt(before))) before--; + return (before >= 0) ? text.charAt(before) : null; + } + + /** Check if the receiver type is a class/type (static context) */ + private boolean isStaticContext(ChainContext ctx) { + // Static context if the previous segment name resolves to a type/class + if (ctx.currentIndex <= 0) + return false; + + String previousSegment = ctx.chain.segments.get(ctx.currentIndex - 1); + int prevPos = ctx.chain.positions.get(ctx.currentIndex - 1)[0]; + + // Use unified static access checker + return TypeResolver.isStaticAccessExpression(previousSegment, prevPos, document); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/InnerCallableScope.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/InnerCallableScope.java new file mode 100644 index 000000000..5e05028d1 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/InnerCallableScope.java @@ -0,0 +1,99 @@ +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.type.TypeInfo; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.HashMap; + +/** + * Represents an inner callable scope (lambda or JS function expression). + * These are NOT added to the methods list - they're resolution-only scopes. + */ +public class InnerCallableScope { + + public enum Kind { + JAVA_LAMBDA, + JS_FUNCTION_EXPR + } + + private final Kind kind; + private final int headerStart; // Start of "(params) ->" or "function(params)" + private final int headerEnd; // End of header (position of "->" for lambda, ")" for JS func) + private final int bodyStart; // Start of body (expression start or '{') + private final int bodyEnd; // End of body (expression end or '}') + private final List parameters = new ArrayList<>(); + private final Map locals = new HashMap<>(); + private InnerCallableScope parentScope; // Enclosing inner scope (for nested lambdas) + private TypeInfo expectedType; // The expected functional interface type (set during resolution) + + public InnerCallableScope(Kind kind, int headerStart, int headerEnd, int bodyStart, int bodyEnd) { + this.kind = kind; + this.headerStart = headerStart; + this.headerEnd = headerEnd; + this.bodyStart = bodyStart; + this.bodyEnd = bodyEnd; + } + + // Getters + public Kind getKind() { return kind; } + public int getHeaderStart() { return headerStart; } + public int getHeaderEnd() { return headerEnd; } + public int getBodyStart() { return bodyStart; } + public int getBodyEnd() { return bodyEnd; } + public List getParameters() { return parameters; } + public Map getLocals() { return locals; } + public InnerCallableScope getParentScope() { return parentScope; } + public TypeInfo getExpectedType() { return expectedType; } + + // Setters + public void setParentScope(InnerCallableScope parent) { this.parentScope = parent; } + public void setExpectedType(TypeInfo type) { this.expectedType = type; } + + public void addParameter(FieldInfo param) { + parameters.add(param); + } + + public void addLocal(String name, FieldInfo local) { + locals.put(name, local); + } + + public FieldInfo getParameter(String name) { + for (FieldInfo param : parameters) { + if (param.getName().equals(name)) { + return param; + } + } + return null; + } + + public boolean hasParameter(String name) { + return getParameter(name) != null; + } + + /** + * Check if a position is inside this scope's body. + */ + public boolean containsPosition(int position) { + return position >= bodyStart && position < bodyEnd; + } + + /** + * Check if a position is inside this scope's header (parameter list). + */ + public boolean containsHeaderPosition(int position) { + return position >= headerStart && position < headerEnd; + } + + /** + * Get the full range (header + body). + */ + public int getFullStart() { + return headerStart; + } + + public int getFullEnd() { + return bodyEnd; + } +} 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 new file mode 100644 index 000000000..54c7a6d37 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java @@ -0,0 +1,7584 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.gui.util.script.interpreter.expression.CastExpressionResolver; +import noppes.npcs.client.gui.util.script.interpreter.expression.ExpressionNode; +import noppes.npcs.client.gui.util.script.interpreter.expression.ExpressionTypeResolver; +import noppes.npcs.client.gui.util.script.interpreter.field.AssignmentInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSScriptAnalyzer; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocParamTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocParser; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodSignature; +import noppes.npcs.client.gui.util.script.interpreter.token.Token; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenErrorMessage; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.*; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticField; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticMethod; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticType; +import noppes.npcs.constants.ScriptContext; +import noppes.npcs.controllers.data.DataScript; + +import java.util.*; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * The main document container that manages script text, lines, and tokens. + * This is the clean reimplementation of JavaTextContainer. + * + * Architecture: + * - ScriptDocument holds the complete source text and global state + * - ScriptLine holds individual lines with their tokens + * - Token holds individual syntax elements with type-specific metadata + * - TypeResolver handles all class/type resolution + * + * Single-pass tokenization: + * 1. Parse excluded regions (comments, strings) + * 2. Parse imports and resolve types + * 3. Parse structure (methods, classes, fields) + * 4. Tokenize with all context available + */ +public class ScriptDocument { + + public static ScriptDocument INSTANCE = null; // For easy access in expressions + // ==================== PATTERNS ==================== + + private static final Pattern WORD_PATTERN = Pattern.compile("[\\p{L}\\p{N}_-]+|\\n|$"); + + // Literals + private static final Pattern STRING_PATTERN = Pattern.compile("([\"'])(?:(?=(\\\\?))\\2.)*?\\1"); + private static final Pattern COMMENT_PATTERN = Pattern.compile("/\\*[\\s\\S]*?(?:\\*/|$)|//.*|#.*"); + private static final Pattern NUMBER_PATTERN = Pattern.compile( + "\\b-?(?:0[xX][\\dA-Fa-f]+|0[bB][01]+|0[oO][0-7]+|\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?(?:[fFbBdDlLsS])?|NaN|null|Infinity|true|false)\\b"); + + // Keywords + private static final Pattern MODIFIER_PATTERN = Pattern.compile( + "\\b(public|protected|private|static|final|abstract|synchronized|native|default)\\b"); + private static final Pattern KEYWORD_PATTERN = Pattern.compile( + "\\b(null|boolean|int|float|double|long|char|byte|short|void|if|else|switch|case|for|while|do|try|catch|finally|return|throw|var|let|const|function|continue|break|this|super|new|typeof|instanceof|import)\\b"); + + // Declarations - Updated to capture method parameters + private static final Pattern IMPORT_PATTERN = Pattern.compile( + "(?m)\\bimport\\s+(?:static\\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*)*)(?:\\s*\\.\\s*\\*?)?\\s*(?:;|$)"); + private static final Pattern CLASS_DECL_PATTERN = Pattern.compile( + "\\b(class|interface|enum)\\s+([A-Za-z_][a-zA-Z0-9_]*)"); + private static final Pattern METHOD_DECL_PATTERN = Pattern.compile( + "\\b([A-Za-z_][a-zA-Z0-9_<>\\[\\]]*)\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\("); + private static final Pattern METHOD_CALL_PATTERN = Pattern.compile( + "([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\("); + private static final Pattern FIELD_DECL_PATTERN = Pattern.compile( + "\\b([A-Za-z_][a-zA-Z0-9_<>,[ \\t]\\[\\]]*)[ \\t]+([a-zA-Z_][a-zA-Z0-9_]*)[ \\t]*(=|;)"); + private static final Pattern NEW_TYPE_PATTERN = Pattern.compile("\\bnew\\s+([A-Za-z_][a-zA-Z0-9_]*)"); + + // Cast expression pattern: captures type name inside cast parentheses + // Matches: (Type), (Type[]), (pkg.Type), (Type) + private static final Pattern CAST_TYPE_PATTERN = Pattern.compile( + "\\(\\s*([A-Za-z_][a-zA-Z0-9_]*(?:\\s*\\.\\s*[A-Za-z_][a-zA-Z0-9_]*)*)\\s*(?:<[^>]*>)?\\s*(\\[\\s*\\])*\\s*\\)\\s*(?=[a-zA-Z_\"'(\\d!~+-])"); + + // Function parameters (for JS-style scripts) + private static final Pattern FUNC_PARAMS_PATTERN = Pattern.compile( + "\\bfunction\\s+[a-zA-Z_][a-zA-Z0-9_]*\\s*\\(([^)]*)\\)"); + + // ==================== STATE ==================== + + private String text = ""; + private final List lines = new ArrayList<>(); + + private final List imports = new ArrayList<>(); + private final Set wildcardPackages = new HashSet<>(); + private Map importsBySimpleName = new HashMap<>(); + + // Script-defined types (classes, interfaces, enums defined in the script) + private final Map scriptTypes = new HashMap<>(); + + private final List methods = new ArrayList<>(); + private final Map globalFields = new HashMap<>(); + // Local variables per method (methodStartOffset -> {varName -> FieldInfo}) + private final Map> methodLocals = new HashMap<>(); + + // Inner callable scopes (lambdas, JS function expressions) - NOT in methods list + private final List innerScopes = new ArrayList<>(); + + // Method calls - stores all parsed method call information + private final List methodCalls = new ArrayList<>(); + // Field accesses - stores all parsed field access information + private final List fieldAccesses = new ArrayList<>(); + // Assignments to external fields (fields from reflection, not script-defined) + private final List externalFieldAssignments = new ArrayList<>(); + + // Declaration errors (duplicate declarations, etc.) + private final List declarationErrors = new ArrayList<>(); + + // Excluded regions (strings/comments) - positions where other patterns shouldn't match + private final List excludedRanges = new ArrayList<>(); + + // SAM context tracking for named function references + // Maps method name -> first SAM signature applied (for conflict detection) + private final Map scriptMethodSamContexts = new HashMap<>(); + + // Thread-local to communicate SAM conflict errors from resolveIdentifier back to parseMethodArguments + // Set when injectSamParameterTypes detects a conflict, cleared after argument is created + private static final ThreadLocal CURRENT_SAM_CONFLICT_ERROR = new ThreadLocal<>(); + + // Type resolver + private final TypeResolver typeResolver; + + // JSDoc parser for extracting type information from JSDoc comments + private final JSDocParser jsDocParser = new JSDocParser(this); + + // Script language: "ECMAScript", "Groovy", etc. + private String language = "ECMAScript"; + + // Script context: NPC, PLAYER, BLOCK, ITEM, etc. + // Determines which hooks and event types are available + private ScriptContext scriptContext = ScriptContext.GLOBAL; + + // Editor-injected globals (name -> type name) provided by the handler + private Map editorGlobals = Collections.emptyMap(); + + // Implicit imports from JaninoScript default imports and hook parameter types + // These are types that should be resolved even without explicit import statements + private final Set implicitImports = new HashSet<>(); + + // Layout properties + public int lineHeight = 13; + public int totalHeight; + public int visibleLines = 1; + public int linesCount; + + // ==================== CONSTRUCTOR ==================== + + public ScriptDocument(String text) { + this.typeResolver = TypeResolver.getInstance(); + setText(text); + } + + public ScriptDocument(String text, TypeResolver resolver) { + this.typeResolver = resolver != null ? resolver : TypeResolver.getInstance(); + setText(text); + } + + public ScriptDocument(String text, String language) { + this.typeResolver = TypeResolver.getInstance(); + this.language = language != null ? language : "ECMAScript"; + setText(text); + } + + /** + * Set the scripting language. + */ + public void setLanguage(String language) { + this.language = language != null ? language : "ECMAScript"; + } + + /** + * Get the scripting language. + */ + public String getLanguage() { + return language; + } + + /** + * Check if this is a JavaScript/ECMAScript document. + */ + public boolean isJavaScript() { + return "ECMAScript".equalsIgnoreCase(language); + } + + /** + * Set the script context (NPC, PLAYER, BLOCK, ITEM, etc.). + * This determines which hooks and event types are available for autocomplete. + * + * @param context The script context + */ + public void setScriptContext(ScriptContext context) { + this.scriptContext = context != null ? context : ScriptContext.GLOBAL; + } + + /** + * Get the current script context. + * + * @return The script context (NPC, PLAYER, BLOCK, ITEM, etc.) + */ + public ScriptContext getScriptContext() { + return scriptContext; + } + + /** + * Set editor-injected globals (name -> type name). + * i.e. DataScript global definitions {@link DataScript#getEditorGlobals(String)} + * This is provided by the script handler at editor init time. + */ + public void setEditorGlobals(Map globals) { + if (globals == null || globals.isEmpty()) { + this.editorGlobals = Collections.emptyMap(); + return; + } + this.editorGlobals = new LinkedHashMap<>(globals); + } + + /** + * Get editor-injected globals (name -> type name). + */ + public Map getEditorGlobals() { + return Collections.unmodifiableMap(editorGlobals); + } + + /** + * Add implicit imports that should be resolved without explicit import statements. + * Used for JaninoScript default imports and hook parameter types. + * + * Patterns can be: + * - Wildcard: "noppes.npcs.api.*" (imports all classes from package) + * - Specific class: "noppes.npcs.api.event.INpcEvent$InitEvent" (nested class) + * - Regular class: "noppes.npcs.api.entity.IEntity" + * + * @param patterns Array of import patterns to add (packages with .* or fully qualified class names) + */ + public void addImplicitImports(String... patterns) { + if (patterns != null) { + for (String pattern : patterns) { + if (pattern != null && !pattern.isEmpty()) { + this.implicitImports.add(pattern); + } + } + } + } + + // ==================== TEXT MANAGEMENT ==================== + + public void setText(String text) { + this.text = text != null ? text.replaceAll("\\r?\\n|\\r", "\n") : ""; + } + + public String getText() { + return text; + } + + // ==================== INITIALIZATION ==================== + + /** + * Initialize the document with layout constraints. + * Builds lines based on width wrapping. + */ + public void init(int width, int height) { + lines.clear(); + lineHeight = ClientProxy.Font.height(); + if (lineHeight == 0) + lineHeight = 12; + + String[] sourceLines = text.split("\n", -1); + int totalChars = 0; + int lineIndex = 0; + + for (String sourceLine : sourceLines) { + StringBuilder currentLine = new StringBuilder(); + Matcher m = WORD_PATTERN.matcher(sourceLine); + int i = 0; + + while (m.find()) { + String word = sourceLine.substring(i, m.start()); + if (ClientProxy.Font.width(currentLine + word) > width - 10) { + // Wrap line + int lineStart = totalChars; + int lineEnd = totalChars + currentLine.length(); + lines.add(new ScriptLine(currentLine.toString(), lineStart, lineEnd, lineIndex++)); + totalChars = lineEnd; + currentLine = new StringBuilder(); + } + currentLine.append(word); + i = m.start(); + } + + // Add final line segment (including newline character in range) + int lineStart = totalChars; + int lineEnd = totalChars + currentLine.length() + 1; + lines.add(new ScriptLine(currentLine.toString(), lineStart, lineEnd, lineIndex++)); + totalChars = lineEnd; + } + + // Set up line navigation + for (int li = 0; li < lines.size(); li++) { + ScriptLine line = lines.get(li); + line.setParent(this); + if (li > 0) { + line.setPrev(lines.get(li - 1)); + lines.get(li - 1).setNext(line); + } + } + + linesCount = lines.size(); + totalHeight = linesCount * lineHeight; + visibleLines = Math.max(height / lineHeight - 1, 1); + INSTANCE = this; + } + + // ==================== TOKENIZATION ==================== + + /** + * Main tokenization entry point. + * Performs complete analysis and builds tokens for all lines. + * Uses the SAME unified pipeline for both Java and JavaScript. + * + * All data structures (methods, globalFields, methodLocals, methodCalls, + * fieldAccesses, etc.) are shared between languages. + */ + public void formatCodeText() { + // Clear previous state (same for both languages) + imports.clear(); + methods.clear(); + globalFields.clear(); + wildcardPackages.clear(); + excludedRanges.clear(); + methodLocals.clear(); + scriptTypes.clear(); + innerScopes.clear(); + methodCalls.clear(); + externalFieldAssignments.clear(); + declarationErrors.clear(); + scriptMethodSamContexts.clear(); + + // Unified pipeline for both languages + List marks = formatUnified(); + + // Phase 5: Resolve conflicts and sort + marks = resolveConflicts(marks); + + // Phase 6: Build tokens for each line + for (ScriptLine line : lines) { + line.buildTokensFromMarks(marks, text, this); + } + + // Phase 6.5: Now that tokens are built, remove implicit imports that aren't actually used + removeUnusedImplicitImports(); + + // Phase 7: Compute indent guides + computeIndentGuides(marks); + } + + // Store the last JS analyzer for autocomplete (deprecated - use methods/globalFields/methodLocals instead) + @Deprecated + private JSScriptAnalyzer currentJSAnalyzer; + + /** + * @deprecated Use the unified pipeline. Access methods/globalFields/methodLocals directly. + */ + @Deprecated + public JSScriptAnalyzer getJSAnalyzer() { + return currentJSAnalyzer; + } + + /** + * Unified format method - single pipeline for both Java and JavaScript. + * ALL methods called here handle BOTH languages using the SAME data structures. + */ + private List formatUnified() { + // Phase 1: Find excluded regions (strings/comments) - same for both + findExcludedRanges(); + + // Phase 2: Parse imports (Java only, JS skips this) + parseImports(); + + // Phase 3: Parse structure (methods, fields, locals) - language aware + parseStructure(); + + // Phase 4: Build marks - language aware + return buildMarks(); + } + + /** + * @deprecated Use formatUnified() instead. Kept for compatibility. + */ + @Deprecated + private List formatJavaScript() { + // Delegate to unified pipeline + currentJSAnalyzer = new JSScriptAnalyzer(this); + return currentJSAnalyzer.analyze(); + } + + /** + * @deprecated Use formatUnified() instead. Kept for compatibility. + */ + @Deprecated + private List formatJava() { + return formatUnified(); + } + + // ==================== PHASE 1: EXCLUDED RANGES ==================== + + private void findExcludedRanges() { + // Find strings + Matcher m = STRING_PATTERN.matcher(text); + while (m.find()) { + excludedRanges.add(new int[]{m.start(), m.end()}); + } + + // Find comments + m = COMMENT_PATTERN.matcher(text); + while (m.find()) { + excludedRanges.add(new int[]{m.start(), m.end()}); + } + + // Sort and merge overlapping ranges + excludedRanges.sort(Comparator.comparingInt(a -> a[0])); + mergeOverlappingRanges(); + } + + private void mergeOverlappingRanges() { + if (excludedRanges.size() < 2) + return; + + List merged = new ArrayList<>(); + int[] current = excludedRanges.get(0); + + for (int i = 1; i < excludedRanges.size(); i++) { + int[] next = excludedRanges.get(i); + if (next[0] <= current[1]) { + current[1] = Math.max(current[1], next[1]); + } else { + merged.add(current); + current = next; + } + } + merged.add(current); + + excludedRanges.clear(); + excludedRanges.addAll(merged); + } + + + public boolean isExcluded(int position) { + for (int[] range : excludedRanges) { + if (position >= range[0] && position < range[1]) { + return true; + } + } + return false; + } + + /** Inclusive version for precision with end offset + Check GuiScriptTextArea#handleCharacterInput */ + public boolean isExcludedInclusive(int position) { + for (int[] range : excludedRanges) { + if (position >= range[0] && position <= range[1]) { + return true; + } + } + return false; + } + + /** + * Get the list of excluded ranges (comments, strings, etc.). + * Used by AutocompleteManager to skip over excluded regions. + * @return List of [start, end) ranges to exclude + */ + public List getExcludedRanges() { + return excludedRanges; + } + + /** + * Check if a position is within a comment range (not string). + * Used to skip comment text when scanning for identifiers. + */ + private boolean isInCommentRange(int position) { + // Check if position is in a comment by checking against COMMENT_PATTERN + Matcher m = COMMENT_PATTERN.matcher(text); + while (m.find()) { + if (position >= m.start() && position < m.end()) { + return true; + } + } + return false; + } + + // ==================== PHASE 2: IMPORTS ==================== + + private void parseImports() { + // JavaScript doesn't use Java-style imports + if (isJavaScript()) { + return; + } + + Matcher m = IMPORT_PATTERN.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) + continue; + + String fullPath = m.group(1).replaceAll("\\s+", "").trim(); + String matchText = m.group(0); + boolean isWildcard = matchText.contains("*"); + boolean isStatic = matchText.contains("static"); + + // Skip if path ends with dot (incomplete import) + if (fullPath.endsWith(".")) { + // continue; + } + + int lastDot = fullPath.lastIndexOf('.'); + String simpleName = isWildcard ? null : (lastDot >= 0 ? fullPath.substring(lastDot + 1) : fullPath); + + ImportData importData = new ImportData( + fullPath, simpleName, isWildcard, isStatic, + m.start(), m.end(), m.start(1), m.end(1) + ); + imports.add(importData); + + if (isWildcard) { + wildcardPackages.add(fullPath); + } + } + + // Add ALL implicit imports initially so tokens can be built with proper type resolution + addAllImplicitImportsToList(); + + // Resolve all imports (including all implicit ones) + importsBySimpleName = typeResolver.resolveImports(imports); + } + + /** + * Add ALL implicit imports to the imports list initially. + * This allows tokens to be built with proper type resolution. + * After tokenization, we'll remove the unused ones. + */ + private void addAllImplicitImportsToList() { + for (String pattern : implicitImports) { + if (pattern == null || pattern.isEmpty()) continue; + + boolean isWildcard = pattern.endsWith(".*"); + String fullPath; + String simpleName; + + if (isWildcard) { + fullPath = pattern.substring(0, pattern.length() - 2); // Remove ".*" + simpleName = null; + wildcardPackages.add(fullPath); + } else { + fullPath = pattern.replace('$', '.'); + int lastDot = fullPath.lastIndexOf('.'); + simpleName = lastDot >= 0 ? fullPath.substring(lastDot + 1) : fullPath; + } + + ImportData importData = new ImportData( + fullPath, simpleName, isWildcard, false, + -1, -1, -1, -1 + ); + + if (!imports.contains(importData)) { + imports.add(importData); + } + } + } + + /** + * Remove implicit imports that aren't actually used in the text. + * This keeps autocomplete clean by not showing types from hooks that aren't present. + * Called after tokenization is complete. + */ + private void removeUnusedImplicitImports() { + // Collect imports to remove (can't remove during iteration) + List toRemove = new ArrayList<>(); + + for (ImportData imp : imports) { + // Only check implicit imports (those with offset -1) + if (imp.getStartOffset() != -1) continue; + + // Skip wildcard imports - keep them all + if (imp.isWildcard()) continue; + + // Check if this specific class import is actually used + String simpleName = imp.getSimpleName(); + String fullPath = imp.getFullPath(); + + if (!isTypeReferenced(simpleName, fullPath)) { + toRemove.add(imp); + } + } + + // Remove unused implicit imports + imports.removeAll(toRemove); + + // Re-resolve imports after removing unused ones + importsBySimpleName = typeResolver.resolveImports(imports); + } + + /** + * Check if a type is referenced anywhere in the script tokens. + * Looks for the simple name or any part of the fully qualified name. + */ + private boolean isTypeReferenced(String simpleName, String fullPath) { + if (simpleName == null || simpleName.isEmpty()) return false; + + // Check all lines and their tokens + for (ScriptLine line : lines) { + for (Token token : line.getTokens()) { + String tokenText = token.getText(); + + // Check if token matches the simple name + if (tokenText.equals(simpleName)) { + return true; + } + + // Check if token contains nested class reference (e.g., "InitEvent" in "INpcEvent.InitEvent") + if (fullPath.contains("$") && tokenText.equals(simpleName)) { + return true; + } + + // Check for partial matches in qualified references + if (fullPath.contains(".") && tokenText.contains(".")) { + // e.g., "INpcEvent.InitEvent" matches "noppes.npcs.api.event.INpcEvent$InitEvent" + String[] pathParts = fullPath.replace('$', '.').split("\\."); + String[] tokenParts = tokenText.split("\\."); + + // Check if the last parts match (e.g., "InitEvent") + if (pathParts.length > 0 && tokenParts.length > 0) { + String lastPathPart = pathParts[pathParts.length - 1]; + String lastTokenPart = tokenParts[tokenParts.length - 1]; + if (lastTokenPart.equals(lastPathPart)) { + return true; + } + } + } + } + } + + return false; + } + + + // ==================== PHASE 3: STRUCTURE ==================== + + /** + * Parse the script structure - methods, fields, variables. + * Uses the SAME logic and data structures for both Java and JavaScript. + */ + private void parseStructure() { + // Clear import references before re-parsing + for (ImportData imp : imports) { + imp.clearReferences(); + } + + // Parse script-defined types (classes, interfaces, enums) - Java only + if (!isJavaScript()) { + parseScriptTypes(); + } + + // Parse methods/functions - UNIFIED for both languages + parseMethodDeclarations(); + + // Parse inner callable scopes (lambdas, JS function expressions) + parseInnerCallableScopes(); + + // Parse local variables inside methods/functions - UNIFIED for both languages + parseLocalVariables(); + + // Parse global fields (outside methods) - UNIFIED for both languages + parseGlobalFields(); + + // Parse and validate assignments (reassignments) - UNIFIED for both languages + parseAssignments(); + + // Detect method overrides and interface implementations for script types - Java only + if (!isJavaScript()) { + detectMethodInheritance(); + } + } + + /** + * Parse class, interface, and enum declarations defined in the script. + * Creates ScriptTypeInfo instances and stores them for later resolution. + * Java only - JavaScript doesn't use class declarations in the same way. + */ + private void parseScriptTypes() { + // Pattern: [modifiers] (class|interface|enum) ClassName [optional ()] [extends Parent] [implements I1, I2...] { ... } + // Matches optional modifiers (public, private, static, final, abstract) before class/interface/enum + // Also allows optional () after the class name (common mistake) + // Groups: 1=class|interface|enum, 2=TypeName, 3=extends clause (optional), 4=implements clause (optional) + Pattern typeDecl = Pattern.compile( + "(?:(?:public|private|protected|static|final|abstract)\\s+)*(class|interface|enum)\\s+([A-Za-z_][a-zA-Z0-9_]*)\\s*(?:\\(\\))?\\s*(?:extends\\s+([A-Za-z_][a-zA-Z0-9_.]*))?\\s*(?:implements\\s+([A-Za-z_][a-zA-Z0-9_.,\\s]*))?\\s*\\{"); + + Matcher m = typeDecl.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) + continue; + + String kindStr = m.group(1); + String typeName = m.group(2); + String extendsClause = m.group(3); // e.g., "ParentClass" or null + String implementsClause = m.group(4); // e.g., "Interface1, Interface2" or null + + int bodyStart = text.indexOf('{', m.start()); + int bodyEnd = findMatchingBrace(bodyStart); + + if (bodyEnd < 0) { + bodyEnd = text.length(); + } + + TypeInfo.Kind kind; + switch (kindStr) { + case "interface": kind = TypeInfo.Kind.INTERFACE; break; + case "enum": kind = TypeInfo.Kind.ENUM; break; + default: kind = TypeInfo.Kind.CLASS; break; + } + + // Extract modifiers from the matched text + String fullMatch = text.substring(m.start(), m.end()); + int modifiers = parseModifiers(fullMatch); + + JSDocInfo jsDoc = jsDocParser.extractJSDocBefore(text, m.start()); + + ScriptTypeInfo scriptType = ScriptTypeInfo.create( + typeName, kind, m.start(), bodyStart, bodyEnd, modifiers); + + if (jsDoc != null) { + scriptType.setJSDocInfo(jsDoc); + } + + // Store the script type first so it can be resolved by other types + scriptTypes.put(typeName, scriptType); + + // Process extends clause (parent class) + if (extendsClause != null && !extendsClause.trim().isEmpty()) { + String parentName = extendsClause.trim(); + TypeInfo parentType = resolveType(parentName); + if (parentType == null) { + // Create unresolved type info for display purposes + parentType = TypeInfo.unresolved(parentName, parentName); + } + scriptType.setSuperClass(parentType, parentName); + } + + // Process implements clause (interfaces) + if (implementsClause != null && !implementsClause.trim().isEmpty()) { + String[] interfaceNames = implementsClause.split(","); + for (String ifaceName : interfaceNames) { + String trimmedName = ifaceName.trim(); + if (trimmedName.isEmpty()) + continue; + + TypeInfo ifaceType = resolveType(trimmedName); + if (ifaceType == null) { + // Create unresolved type info for display purposes + ifaceType = TypeInfo.unresolved(trimmedName, trimmedName); + } + scriptType.addImplementedInterface(ifaceType, trimmedName); + } + } + + // Parse fields and methods inside this type AFTER adding the scriptType globally, so its members can reference it + parseScriptTypeMembers(scriptType); + + } + } + + /** + * Parse fields and methods inside a script-defined type. + */ + private void parseScriptTypeMembers(ScriptTypeInfo scriptType) { + int bodyStart = scriptType.getBodyStart(); + int bodyEnd = scriptType.getBodyEnd(); + + if (bodyStart < 0 || bodyEnd <= bodyStart) return; + + String bodyText = text.substring(bodyStart + 1, Math.min(bodyEnd, text.length())); + + // Parse constructors (ClassName(params) { ... }) + // Constructor pattern: ClassName matches the script type name, no return type + String typeName = scriptType.getSimpleName(); + Pattern constructorPattern = Pattern.compile( + "\\b" + Pattern.quote(typeName) + "\\s*\\(([^)]*)\\)\\s*\\{"); + Matcher cm = constructorPattern.matcher(bodyText); + while (cm.find()) { + int absPos = bodyStart + 1 + cm.start(); + if (isExcluded(absPos)) continue; + + String paramList = cm.group(1); + + int constructorBodyStart = bodyStart + 1 + cm.end() - 1; + int constructorBodyEnd = findMatchingBrace(constructorBodyStart); + if (constructorBodyEnd < 0) constructorBodyEnd = bodyEnd; + + // Extract documentation before this constructor + String documentation = extractDocumentationBefore(absPos); + + // Extract modifiers by scanning backwards + int modifiers = extractModifiersBackwards(cm.start() - 1, bodyText); + + // Parse parameters with their actual positions + List params = parseParametersWithPositions(paramList, bodyStart + 1 + cm.start(1)); + + // Calculate the three offsets for constructor + // For constructors, type offset and name offset are the same (start of constructor name) + int nameOffset = bodyStart + 1 + cm.start(); + int typeOffset = nameOffset; // No separate type for constructors + int fullDeclOffset = findFullDeclarationStart(cm.start(), bodyText); + if (fullDeclOffset >= 0) { + fullDeclOffset += bodyStart + 1; + } else { + fullDeclOffset = nameOffset; + } + + // Constructors don't have a return type, but we'll use the containing type as a marker + MethodInfo constructorInfo = MethodInfo.declaration( + typeName, + scriptType, + // Return type is the type itself + scriptType, + params, + fullDeclOffset, + typeOffset, + nameOffset, + constructorBodyStart, + constructorBodyEnd, + modifiers, + documentation); + scriptType.addConstructor(constructorInfo); + } + + // Parse enum constants (for enum types only) + if (scriptType.getKind() == TypeInfo.Kind.ENUM) { + List constants = EnumConstantInfo.parseEnumConstants( + scriptType, + bodyText, + bodyStart + 1, + KEYWORD_PATTERN + ); + + for (EnumConstantInfo constant : constants) { + scriptType.addEnumConstant(constant); + if (constant.getConstructorCall() != null) { + + } + // methodCalls.add(constant.getConstructorCall()); + + } + } + } + + /** + * Check if a position is inside a nested method body within a class. + * This prevents field declarations inside methods from being treated as class fields. + */ + private boolean isInsideNestedMethod(int position, int classBodyStart, int classBodyEnd) { + String bodyText = text.substring(classBodyStart + 1, Math.min(classBodyEnd, text.length())); + int relativePos = position - classBodyStart - 1; + + Pattern methodPattern = Pattern.compile( + "\\b[A-Za-z_][a-zA-Z0-9_<>\\[\\]]*\\s+[a-zA-Z_][a-zA-Z0-9_]*\\s*\\([^)]*\\)\\s*\\{"); + Matcher m = methodPattern.matcher(bodyText); + + while (m.find()) { + int methodBodyStart = m.end() - 1; + int absMethodBodyStart = classBodyStart + 1 + methodBodyStart; + int absMethodBodyEnd = findMatchingBrace(absMethodBodyStart); + + if (absMethodBodyEnd > 0 && position > absMethodBodyStart && position < absMethodBodyEnd) { + return true; + } + } + return false; + } + + /** + * Parse method/function declarations - UNIFIED for both Java and JavaScript. + * Stores results in the shared 'methods' list. + * + * For Java: Parses "ReturnType methodName(params) { ... }" + * For JavaScript: Parses "function funcName(params) { ... }" with hook type inference + */ + private void parseMethodDeclarations() { + if (isJavaScript()) { + // JavaScript: function funcName(params) { ... } + Pattern funcPattern = Pattern.compile("function\\s+(\\w+)\\s*\\(([^)]*)\\)\\s*\\{"); + Matcher m = funcPattern.matcher(text); + + while (m.find()) { + if (isExcluded(m.start())) continue; + + String funcName = m.group(1); + String paramList = m.group(2); + + int nameStart = m.start(1); + int bodyStart = text.indexOf('{', m.end() - 1); + int bodyEnd = findMatchingBrace(bodyStart); + if (bodyEnd < 0) bodyEnd = text.length(); + + // Extract documentation before this method + String documentation = extractDocumentationBefore(m.start()); + + // Check for JSDoc before the function declaration + JSDocInfo jsDoc = jsDocParser.extractJSDocBefore(text, m.start()); + + // For JS hooks, infer parameter types from registry + // Use the script context's namespaces (e.g., ["IPlayerEvent", "IAnimationEvent"]) for lookup + List params = new ArrayList<>(); + TypeInfo returnType = TypeInfo.fromPrimitive("void"); + List namespaces = scriptContext != null ? scriptContext.getNamespaces() : Collections.singletonList("Global"); + + if (typeResolver.isJSHook(namespaces, funcName)) { + List sigs = typeResolver.getJSHookSignatures(namespaces, funcName); + if (!sigs.isEmpty()) { + JSTypeRegistry.HookSignature sig = sigs.get(0); + documentation = sig.doc; + + // Infer parameter types from hook signature + if (paramList != null && !paramList.trim().isEmpty()) { + String[] paramNames = paramList.split(","); + if (paramNames.length > 0) { + String paramName = paramNames[0].trim(); + TypeInfo paramType = typeResolver.resolveJSType(sig.paramType); + int paramStart = m.start(2) + paramList.indexOf(paramName); + params.add(FieldInfo.parameter(paramName, paramType, paramStart, null)); + } + } + } + } else { + // Non-hook function - use JSDoc param types if available, else 'any' type + if (paramList != null && !paramList.trim().isEmpty()) { + int paramOffset = m.start(2); + for (String p : paramList.split(",")) { + String pn = p.trim(); + if (!pn.isEmpty()) { + int paramStart = paramOffset + paramList.indexOf(pn); + + // Check if JSDoc has a @param tag for this parameter + TypeInfo paramType = TypeInfo.unresolved("any", "any"); + if (jsDoc != null) { + JSDocParamTag paramTag = jsDoc.getParamTag(pn); + if (paramTag != null && paramTag.getTypeInfo() != null) { + paramType = paramTag.getTypeInfo(); + } + } + + params.add(FieldInfo.parameter(pn, paramType, paramStart, null)); + } + } + } + + // Use JSDoc @return type if available + if (jsDoc != null && jsDoc.hasReturnTag() && jsDoc.getReturnType() != null) { + returnType = jsDoc.getReturnType(); + } + } + + // Create MethodInfo and add to shared methods list + MethodInfo methodInfo = MethodInfo.declaration( + funcName, null, returnType, params, + m.start(), nameStart, nameStart, + bodyStart, bodyEnd, 0, documentation + ); + + if(jsDoc!=null) + methodInfo.setJSDocInfo(jsDoc); + + methods.add(methodInfo); + } + } else { + // Java: ReturnType methodName(params) { ... } or { ; } + Pattern methodWithBody = Pattern.compile( + "\\b([a-zA-Z_][a-zA-Z0-9_<>\\[\\]]*)\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(([^)]*)\\)\\s*(\\{|;)"); + + Matcher m = methodWithBody.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) + continue; + + String returnType = m.group(1); + + if (returnType.equals("class") || returnType.equals("interface") || returnType.equals("enum") || returnType.equals("new")) { + continue; + } + + String methodName = m.group(2); + String paramList = m.group(3); + String delimiter = m.group(4); + boolean hasBody = delimiter.equals("{"); + + int bodyStart = !hasBody ? m.end() : text.indexOf('{', m.end() - 1); + int bodyEnd = !hasBody ? m.end() : findMatchingBrace(bodyStart); + if (bodyEnd < 0) + bodyEnd = text.length(); + + // Extract documentation before this method + String documentation = extractDocumentationBefore(m.start()); + + // Extract modifiers by scanning backwards from match start + int modifiers = extractModifiersBackwards(m.start() - 1, text); + + // Calculate offset positions + int typeOffset = m.start(1); + int nameOffset = m.start(2); + int fullDeclOffset = findFullDeclarationStart(m.start(1), text); + + ScriptTypeInfo scriptType = null; + for (ScriptTypeInfo type : scriptTypes.values()) + if (type.containsPosition(bodyStart)) { + scriptType = type; + break; + } + + // Parse parameters with their actual positions + List params = parseParametersWithPositions(paramList, m.start(3)); + + MethodInfo methodInfo = MethodInfo.declaration( + methodName, + scriptType, + resolveType(returnType), + params, + fullDeclOffset, + typeOffset, + nameOffset, + bodyStart, + bodyEnd, + modifiers, + documentation + ); + + if (scriptType != null) { + scriptType.addMethod(methodInfo); + } else + methods.add(methodInfo); + + // Validate the method (return statements, parameters) with type resolution + String methodBodyText = bodyEnd > bodyStart + 1 ? text.substring(bodyStart + 1, bodyEnd) : ""; + methodInfo.validate(methodBodyText, hasBody, (expr, pos) -> resolveExpressionType(expr, pos)); + } + + + } + // Check for duplicate method declarations + checkDuplicateMethods(); + } + + /** + * Find the start of a full method declaration, including any modifiers before the type. + */ + private int findFullDeclarationStart(int typeStart, String sourceText) { + int pos = typeStart - 1; + + // Skip whitespace before type + while (pos >= 0 && Character.isWhitespace(sourceText.charAt(pos))) { + pos--; + } + + // Check for modifiers + int earliestPos = typeStart; + while (pos >= 0) { + // Skip whitespace + while (pos >= 0 && Character.isWhitespace(sourceText.charAt(pos))) { + pos--; + } + if (pos < 0) break; + + // Read word backwards + int wordEnd = pos + 1; + while (pos >= 0 && Character.isJavaIdentifierPart(sourceText.charAt(pos))) { + pos--; + } + int wordStart = pos + 1; + + if (wordStart < wordEnd) { + String word = sourceText.substring(wordStart, wordEnd); + if (TypeResolver.isModifier(word)) { + earliestPos = wordStart; + } else { + break; + } + } else { + break; + } + } + + return earliestPos; + } + + /** + * Check for duplicate method declarations with same signature in same scope. + */ + private void checkDuplicateMethods() { + // Group methods by their containing type (script class/interface or document root) + // Methods are only duplicates if they're in the SAME containing type + Map> methodsByType = new HashMap<>(); + + for (MethodInfo method : getAllMethods()) { + TypeInfo containingType = method.getContainingType(); + methodsByType.computeIfAbsent(containingType, k -> new ArrayList<>()).add(method); + } + + // Check each type for duplicate method signatures + for (List typeMethods : methodsByType.values()) { + Map> signatureMap = new HashMap<>(); + + for (MethodInfo method : typeMethods) { + MethodSignature signature = method.getSignature(); + signatureMap.computeIfAbsent(signature, k -> new ArrayList<>()).add(method); + } + + // Mark all methods with duplicate signatures within the same type + for (Map.Entry> entry : signatureMap.entrySet()) { + List duplicates = entry.getValue(); + if (duplicates.size() > 1) { + for (MethodInfo duplicate : duplicates) { + duplicate.setError( + MethodInfo.ErrorType.DUPLICATE_METHOD, + duplicate.getSignature() + " is already defined in this scope" + ); + } + } + } + } + } + + /** + * Calculate the scope depth of a position (0 = top level, 1 = in class, etc.). + */ + private int calculateScopeDepth(int position) { + int depth = 0; + int braceDepth = 0; + boolean inString = false; + boolean inComment = false; + boolean inLineComment = false; + + for (int i = 0; i < position && i < text.length(); i++) { + char c = text.charAt(i); + char next = (i + 1 < text.length()) ? text.charAt(i + 1) : 0; + + // Handle strings + if (c == '"' && !inComment && !inLineComment) { + inString = !inString; + continue; + } + if (inString) continue; + + // Handle comments + if (!inComment && !inLineComment && c == '/' && next == '/') { + inLineComment = true; + i++; + continue; + } + if (!inComment && !inLineComment && c == '/' && next == '*') { + inComment = true; + i++; + continue; + } + if (inComment && c == '*' && next == '/') { + inComment = false; + i++; + continue; + } + if (inLineComment && c == '\n') { + inLineComment = false; + continue; + } + if (inComment || inLineComment) continue; + + // Track braces + if (c == '{') { + braceDepth++; + depth = Math.max(depth, braceDepth); + } else if (c == '}') { + braceDepth--; + } + } + + return depth; + } + + private List parseParametersWithPositions(String paramList, int paramListStart) { + List params = new ArrayList<>(); + if (paramList == null || paramList.trim().isEmpty()) { + return params; + } + + // Pattern: Type varName (with optional spaces) + Pattern paramPattern = Pattern.compile( + "([a-zA-Z_][a-zA-Z0-9_<>\\[\\]]*(?:\\.{3})?)\\s+([a-zA-Z_][a-zA-Z0-9_]*)"); + Matcher m = paramPattern.matcher(paramList); + while (m.find()) { + String typeName = m.group(1); + String paramName = m.group(2); + TypeInfo typeInfo = resolveType(typeName); + // Store the absolute position of the parameter name + int paramNameStart = paramListStart + m.start(2); + params.add(FieldInfo.parameter(paramName, typeInfo, paramNameStart, null)); + } + return params; + } + + /** + * Parse local variables inside methods/functions - UNIFIED for both Java and JavaScript. + * Stores results in the shared 'methodLocals' map (methodOffset -> varName -> FieldInfo). + * + * For Java: Parses "Type varName = expr;" or "Type varName;" + * For JavaScript: Parses "var/let/const varName = expr;" with type inference + */ + private void parseLocalVariables() { + for (MethodInfo method : getAllMethods()) { + Map locals = new HashMap<>(); + methodLocals.put(method.getDeclarationOffset(), locals); + + int bodyStart = method.getBodyStart(); + int bodyEnd = method.getBodyEnd(); + if (bodyStart < 0 || bodyEnd <= bodyStart) continue; + + String bodyText = text.substring(bodyStart, Math.min(bodyEnd, text.length())); + + if (isJavaScript()) { + // JavaScript: var/let/const varName = expr; + Pattern varPattern = Pattern.compile("(?:var|let|const)\\s+(\\w+)(?:\\s*(=)\\s*([^;\\n]+))?"); + Matcher m = varPattern.matcher(bodyText); + + while (m.find()) { + int absPos = bodyStart + m.start(1); + if (isExcluded(absPos)) continue; + + String varName = m.group(1); + String initializer = m.group(3); + + // Check for JSDoc type annotation before the declaration + int absStart = bodyStart + m.start(); + JSDocInfo jsDoc = jsDocParser.extractJSDocBefore(text, absStart); + + TypeInfo typeInfo = null; + + // Priority 1: JSDoc @type takes precedence + if (jsDoc != null && jsDoc.hasTypeTag()) { + typeInfo = jsDoc.getDeclaredType(); + } + + // Priority 2: Infer type from initializer if no JSDoc type + if (typeInfo == null && initializer != null && !initializer.trim().isEmpty()) { + typeInfo = resolveExpressionType(initializer.trim(), bodyStart + m.start(3)); + } + + // Priority 3: Use "any" type for uninitialized variables + if (typeInfo == null) { + typeInfo = TypeInfo.ANY; + } + + int initStart = -1, initEnd = -1; + if (m.group(2) != null) { + // Include the = sign in initStart + initStart = bodyStart + m.start(2); + initEnd = bodyStart + m.end(3); + } + + FieldInfo fieldInfo = FieldInfo.localField(varName, typeInfo, absPos, method, initStart, initEnd, 0); + + if(jsDoc != null) + fieldInfo.setJSDocInfo(jsDoc); + // Check for duplicate + if (locals.containsKey(varName) || globalFields.containsKey(varName)) { + AssignmentInfo dupError = AssignmentInfo.duplicateDeclaration( + varName, absPos, absPos + varName.length(), + "Variable '" + varName + "' is already defined in the scope"); + declarationErrors.add(dupError); + } else { + locals.put(varName, fieldInfo); + } + } + } else { + // Java: Type varName = expr; or Type varName; + Matcher m = FIELD_DECL_PATTERN.matcher(bodyText); + while (m.find()) { + String typeName = m.group(1); + String varName = m.group(2); + String delimiter = m.group(3); + + int absPos = bodyStart + m.start(2); + if (isExcluded(absPos)) continue; + + // Skip control flow keywords + if (typeName.equals("return") || typeName.equals("if") || typeName.equals("while") || + typeName.equals("for") || typeName.equals("switch") || typeName.equals("catch") || + typeName.equals("new") || typeName.equals("throw")) { + continue; + } + + int modifiers = parseModifiers(typeName); + + TypeInfo typeInfo; + if ((typeName.equals("var") || typeName.equals("let") || typeName.equals("const")) + && delimiter.equals("=")) { + int rhsStart = bodyStart + m.end(); + typeInfo = inferTypeFromExpression(rhsStart); + } else { + typeInfo = resolveType(typeName); + } + + int initStart = -1, initEnd = -1; + if ("=".equals(delimiter)) { + initStart = bodyStart + m.start(3); + int searchPos = bodyStart + m.end(3); + int depth = 0; + int angleDepth = 0; + while (searchPos < text.length()) { + char c = text.charAt(searchPos); + if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') depth--; + else if (c == '<') angleDepth++; + else if (c == '>') angleDepth--; + else if ((c == ';' || c == ',') && depth == 0 && angleDepth == 0) { + initEnd = searchPos; + break; + } + searchPos++; + } + } + + int declPos = bodyStart + m.start(2); + FieldInfo fieldInfo = FieldInfo.localField(varName, typeInfo, declPos, method, initStart, initEnd, modifiers); + + if (locals.containsKey(varName) || globalFields.containsKey(varName)) { + AssignmentInfo dupError = AssignmentInfo.duplicateDeclaration( + varName, declPos, declPos + varName.length(), + "Variable '" + varName + "' is already defined in the scope"); + declarationErrors.add(dupError); + } else if (!locals.containsKey(varName)) + locals.put(varName, fieldInfo); + } + } + } + + // Also parse locals inside inner callable scopes + for (InnerCallableScope scope : innerScopes) { + parseLocalVariablesInScope(scope); + } + } + + /** + * Parse local variable declarations inside an inner callable scope (lambda or JS function expression). + */ + private void parseLocalVariablesInScope(InnerCallableScope scope) { + int start = scope.getBodyStart(); + int end = scope.getBodyEnd(); + + if (start < 0 || end <= start) return; + + if (isJavaScript()) { + parseJSLocalsInRange(start, end, scope); + } else { + parseJavaLocalsInRange(start, end, scope); + } + } + + /** + * Parse Java local variable declarations in the range [start, end). + * Pattern: Type varName = ... or var varName = ... + */ + private void parseJavaLocalsInRange(int start, int end, InnerCallableScope scope) { + String rangeText = text.substring(start, Math.min(end, text.length())); + + Matcher m = FIELD_DECL_PATTERN.matcher(rangeText); + while (m.find()) { + int declPos = start + m.start(); + if (declPos < start || declPos >= end) continue; + if (isExcluded(declPos)) continue; + + String typeNameRaw = m.group(1); + String varName = m.group(2); + String delimiter = m.group(3); + + // Skip control flow keywords + if (typeNameRaw.equals("return") || typeNameRaw.equals("if") || typeNameRaw.equals("while") || + typeNameRaw.equals("for") || typeNameRaw.equals("switch") || typeNameRaw.equals("catch") || + typeNameRaw.equals("new") || typeNameRaw.equals("throw")) { + continue; + } + + String typeName = stripModifiers(typeNameRaw); + int modifiers = parseModifiers(typeNameRaw); + + TypeInfo varType = null; + if (!typeName.equals("var") && !typeName.equals("let") && !typeName.equals("const")) { + varType = resolveType(typeName); + } else if (delimiter.equals("=")) { + // Infer type from initializer + int rhsStart = start + m.end(); + varType = inferTypeFromExpression(rhsStart); + } + + int initStart = -1, initEnd = -1; + if ("=".equals(delimiter)) { + initStart = start + m.start(3); + int searchPos = start + m.end(3); + int depth = 0; + int angleDepth = 0; + while (searchPos < end) { + char c = text.charAt(searchPos); + if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') depth--; + else if (c == '<') angleDepth++; + else if (c == '>') angleDepth--; + else if ((c == ';' || c == ',') && depth == 0 && angleDepth == 0) { + initEnd = searchPos; + break; + } + searchPos++; + } + } + + int absPos = start + m.start(2); + FieldInfo localVar = FieldInfo.localField(varName, varType, absPos, null, initStart, initEnd, modifiers); + scope.addLocal(varName, localVar); + } + } + + /** + * Parse JS local variable declarations in the range [start, end). + * Pattern: var/let/const varName = ... + */ + private void parseJSLocalsInRange(int start, int end, InnerCallableScope scope) { + String rangeText = text.substring(start, Math.min(end, text.length())); + + Pattern varPattern = Pattern.compile("(?:var|let|const)\\s+(\\w+)(?:\\s*(=)\\s*([^;\\n]+))?"); + Matcher m = varPattern.matcher(rangeText); + + while (m.find()) { + int declPos = start + m.start(); + if (declPos < start || declPos >= end) continue; + if (isExcluded(declPos)) continue; + + String varName = m.group(1); + String initializer = m.group(3); + + // Check for JSDoc type annotation + JSDocInfo jsDoc = jsDocParser.extractJSDocBefore(text, declPos); + + TypeInfo varType = null; + + // Priority 1: JSDoc @type + if (jsDoc != null && jsDoc.hasTypeTag()) { + varType = jsDoc.getDeclaredType(); + } + + // Priority 2: Infer from initializer + if (varType == null && initializer != null && !initializer.trim().isEmpty()) { + varType = resolveExpressionType(initializer.trim(), start + m.start(3)); + } + + // Priority 3: Use "any" type + if (varType == null) { + varType = TypeInfo.ANY; + } + + int initStart = -1, initEnd = -1; + if (m.group(2) != null) { + initStart = start + m.start(2); + initEnd = start + m.end(3); + } + + int absPos = start + m.start(1); + FieldInfo localVar = FieldInfo.localField(varName, varType, absPos, null, initStart, initEnd, 0); + + if (jsDoc != null) { + localVar.setJSDocInfo(jsDoc); + } + + scope.addLocal(varName, localVar); + } + } + + /** + * Infer the type of an expression starting at the given position. + * Handles patterns like: + * - "new TypeName()" - returns TypeName + * - "receiver.fieldOrMethod" - resolves chain and returns result type + * - "variable" - looks up variable type + * - Literals like numbers, strings, booleans + */ + private TypeInfo inferTypeFromExpression(int position) { + // Skip whitespace + while (position < text.length() && Character.isWhitespace(text.charAt(position))) + position++; + + if (position >= text.length()) + return null; + + // Check for 'new' keyword + if (text.startsWith("new ", position)) { + position += 4; + while (position < text.length() && Character.isWhitespace(text.charAt(position))) + position++; + + // Read the type name + int typeStart = position; + while (position < text.length() && Character.isJavaIdentifierPart(text.charAt(position))) + position++; + + if (position > typeStart) { + String typeName = text.substring(typeStart, position); + return resolveType(typeName); + } + return null; + } + + // Check for string literal + if (position < text.length() && (text.charAt(position) == '"' || text.charAt(position) == '\'')) { + return resolveType("String"); + } + + // Check for numeric literal + if (position < text.length() && Character.isDigit(text.charAt(position))) { + // Check for float/double (has decimal point or f/d suffix) + int numEnd = position; + boolean hasDecimal = false; + while (numEnd < text.length() && (Character.isDigit(text.charAt(numEnd)) || + text.charAt(numEnd) == '.' || text.charAt(numEnd) == 'f' || + text.charAt(numEnd) == 'd' || text.charAt(numEnd) == 'F' || + text.charAt(numEnd) == 'D' || text.charAt(numEnd) == 'L' || + text.charAt(numEnd) == 'l')) { + if (text.charAt(numEnd) == '.') hasDecimal = true; + numEnd++; + } + String num = text.substring(position, numEnd).toLowerCase(); + if (num.endsWith("f")) return resolveType("float"); + if (num.endsWith("d") || hasDecimal) return resolveType("double"); + if (num.endsWith("l")) return resolveType("long"); + return resolveType("int"); + } + + // Check for boolean literals + if (text.startsWith("true", position) || text.startsWith("false", position)) { + return resolveType("boolean"); + } + + // Check for null + if (text.startsWith("null", position)) { + return null; // Can't infer type from null + } + + // Must be an identifier or chain - read the first identifier + if (Character.isJavaIdentifierStart(text.charAt(position))) { + int identStart = position; + while (position < text.length() && Character.isJavaIdentifierPart(text.charAt(position))) + position++; + String ident = text.substring(identStart, position); + + // Skip whitespace + while (position < text.length() && Character.isWhitespace(text.charAt(position))) + position++; + + // Check if this is the start of a chain (followed by .) + if (position < text.length() && text.charAt(position) == '.') { + // This is a chain like "event.player" or "Minecraft.getMinecraft()" + // We need to resolve the entire chain to get the final type + return inferChainType(ident, identStart, position); + } + + // Check if this is a method call (followed by ()) + if (position < text.length() && text.charAt(position) == '(') { + // Method call - check if it's a script method + if (isScriptMethod(ident)) { + MethodInfo methodInfo = getScriptMethodInfo(ident); + return (methodInfo != null) ? methodInfo.getReturnType() : null; + } + return null; + } + + // Just a variable - resolve its type + FieldInfo varInfo = resolveVariable(ident, identStart); + return (varInfo != null) ? varInfo.getTypeInfo() : null; + } + + return null; + } + + /** + * Infer the type from a chain expression starting with the given identifier. + */ + private TypeInfo inferChainType(String firstIdent, int identStart, int dotPosition) { + TypeInfo currentType = null; + + // Resolve the first segment + TypeInfo typeCheck = resolveType(firstIdent); + if (typeCheck != null && typeCheck.isResolved()) { + // Static access like Event.player or scriptType.field + currentType = typeCheck; + } else { + // Variable access + FieldInfo varInfo = resolveVariable(firstIdent, identStart); + currentType = (varInfo != null) ? varInfo.getTypeInfo() : null; + } + + if (currentType == null || !currentType.isResolved()) + return null; + + // Now resolve the rest of the chain + int pos = dotPosition; + while (pos < text.length() && text.charAt(pos) == '.') { + pos++; // Skip the dot + + // Skip whitespace + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) + pos++; + + if (pos >= text.length() || !Character.isJavaIdentifierStart(text.charAt(pos))) + break; + + // Read the next identifier + int segStart = pos; + while (pos < text.length() && Character.isJavaIdentifierPart(text.charAt(pos))) + pos++; + String segment = text.substring(segStart, pos); + + // Skip whitespace + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) + pos++; + + // Check if this is a method call + if (pos < text.length() && text.charAt(pos) == '(') { + // Method call + if (currentType.hasMethod(segment)) { + // Parse argument types + int closeParen = findMatchingParen(pos); + if (closeParen < 0) return null; + String argsText = text.substring(pos + 1, closeParen).trim(); + TypeInfo[] argTypes = parseArgumentTypes(argsText, pos + 1); + + // Check if this is a synthetic type with dynamic return type resolver (like Java.type()) + SyntheticType syntheticType = null; + if (isJavaScript() && typeResolver.isSyntheticType(firstIdent)) { + syntheticType = typeResolver.getSyntheticType(firstIdent); + } + + if (syntheticType != null && syntheticType.hasMethod(segment)) { + SyntheticMethod synMethod = syntheticType.getMethod(segment); + if (synMethod != null) { + // Try dynamic resolution first + String[] strArgs = TypeResolver.parseStringArguments(argsText); + TypeInfo dynamicType = synMethod.resolveReturnType(strArgs); + if (dynamicType != null) { + currentType = dynamicType; + } else { + // Fall back to static return type + currentType = synMethod.getReturnTypeInfo(); + } + } + } else { + MethodInfo methodInfo = currentType.getBestMethodOverload(segment, argTypes); + currentType = (methodInfo != null) ? methodInfo.getReturnType() : null; + } + + // Skip to after the closing paren + pos = closeParen + 1; + } else { + return null; + } + } else { + // Field access + if (currentType.hasField(segment)) { + FieldInfo fieldInfo = currentType.getFieldInfo(segment); + currentType = (fieldInfo != null) ? fieldInfo.getTypeInfo() : null; + } else { + return null; + } + } + + if (currentType == null || !currentType.isResolved()) + return null; + + // Skip whitespace for next iteration + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) + pos++; + } + + return currentType; + } + + /** + * Parse inner callable scopes (lambdas and JS function expressions). + * These are NOT added to the methods list - they're resolution-only scopes. + */ + private void parseInnerCallableScopes() { + // For Java: detect (params) -> expr and (params) -> { ... } and param -> expr + // For JS: detect function(params) { ... } that are expressions (not declarations) + + if (isJavaScript()) { + parseJSFunctionExpressions(); + } else { + parseJavaLambdas(); + } + + // Sort by header start and set up parent relationships + innerScopes.sort((a, b) -> Integer.compare(a.getHeaderStart(), b.getHeaderStart())); + setupScopeParents(); + } + + private void parseJavaLambdas() { + // Pattern: (params) -> or identifier -> + // Look for -> that's not inside strings/comments + String arrowPattern = "->"; + int pos = 0; + while ((pos = text.indexOf(arrowPattern, pos)) >= 0) { + if (isExcluded(pos)) { + pos += 2; + continue; + } + + // Found a potential lambda arrow + int arrowPos = pos; + + // Look backwards to find the parameter list + int headerStart = findLambdaHeaderStart(arrowPos); + if (headerStart < 0) { + pos += 2; + continue; + } + + // Look forward to find the body end + int bodyStart = arrowPos + 2; + // Skip whitespace after -> + while (bodyStart < text.length() && Character.isWhitespace(text.charAt(bodyStart))) { + bodyStart++; + } + + if (bodyStart >= text.length()) { + pos += 2; + continue; + } + + int bodyEnd; + if (text.charAt(bodyStart) == '{') { + // Block lambda + bodyEnd = findMatchingBrace(bodyStart); + if (bodyEnd < 0) bodyEnd = text.length(); + else bodyEnd++; // Include the closing brace + } else { + // Expression lambda - find end of expression + bodyEnd = findLambdaExpressionEnd(bodyStart); + } + + InnerCallableScope scope = new InnerCallableScope( + InnerCallableScope.Kind.JAVA_LAMBDA, + headerStart, + arrowPos + 2, + bodyStart, + bodyEnd + ); + + // Parse parameters (will be typed later during resolution) + parseLambdaParameters(scope, headerStart, arrowPos); + + innerScopes.add(scope); + pos = bodyEnd; + } + } + + private int findLambdaHeaderStart(int arrowPos) { + // Work backwards from arrow to find start of parameter list + int pos = arrowPos - 1; + + // Skip whitespace + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) { + pos--; + } + + if (pos < 0) return -1; + + if (text.charAt(pos) == ')') { + // Parenthesized params: find matching ( + int depth = 1; + pos--; + while (pos >= 0 && depth > 0) { + if (isExcluded(pos)) { + pos--; + continue; + } + char c = text.charAt(pos); + if (c == ')') depth++; + else if (c == '(') depth--; + pos--; + } + return pos + 1; // Position of '(' + } else if (Character.isJavaIdentifierPart(text.charAt(pos))) { + // Single identifier param (no parens): identifier -> + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) { + pos--; + } + return pos + 1; + } + + return -1; + } + + private int findLambdaExpressionEnd(int start) { + // Find end of expression lambda body + // Ends at: ; , ) ] } (at depth 0) or end of text + int depth = 0; + int angleDepth = 0; + int pos = start; + boolean inString = false; + char stringChar = 0; + + while (pos < text.length()) { + char c = text.charAt(pos); + + // Handle strings + if (!inString && (c == '"' || c == '\'')) { + inString = true; + stringChar = c; + pos++; + continue; + } + if (inString) { + if (c == stringChar && (pos == 0 || text.charAt(pos - 1) != '\\')) { + inString = false; + } + pos++; + continue; + } + + // Track nesting + if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') { + if (depth == 0) return pos; + depth--; + } + else if (c == '<') angleDepth++; + else if (c == '>') angleDepth = Math.max(0, angleDepth - 1); + else if ((c == ';' || c == ',') && depth == 0 && angleDepth == 0) { + return pos; + } + + pos++; + } + + return pos; + } + + private void parseLambdaParameters(InnerCallableScope scope, int headerStart, int arrowPos) { + String headerText = text.substring(headerStart, arrowPos).trim(); + + if (headerText.startsWith("(") && headerText.endsWith(")")) { + // Parenthesized: (a, b) or (Type a, Type b) + String paramsText = headerText.substring(1, headerText.length() - 1).trim(); + if (!paramsText.isEmpty()) { + String[] params = paramsText.split(","); + int offset = headerStart + 1; // After '(' + for (String param : params) { + param = param.trim(); + if (param.isEmpty()) continue; + + // Find position in original text + int paramOffset = text.indexOf(param, offset); + if (paramOffset < 0) paramOffset = offset; + + String[] parts = param.split("\\s+"); + String paramName; + TypeInfo paramType = null; + + if (parts.length >= 2) { + // Typed: Type name + paramName = parts[parts.length - 1]; + String typeName = parts[parts.length - 2]; + paramType = resolveType(typeName); + } else { + // Untyped: just name (type will be inferred from expected FI) + paramName = parts[0]; + } + + int nameOffset = text.indexOf(paramName, paramOffset); + if (nameOffset < 0) nameOffset = paramOffset; + + FieldInfo paramInfo = FieldInfo.parameter(paramName, paramType, nameOffset, null); + scope.addParameter(paramInfo); + + offset = paramOffset + param.length(); + } + } + } else { + // Single identifier: x -> + String paramName = headerText; + int nameOffset = text.indexOf(paramName, headerStart); + if (nameOffset < 0) nameOffset = headerStart; + + FieldInfo paramInfo = FieldInfo.parameter(paramName, null, nameOffset, null); + scope.addParameter(paramInfo); + } + } + + private void parseJSFunctionExpressions() { + // Pattern: function(params) { ... } or function name(params) { ... } + // But NOT function declarations at top level (those are already in methods) + Pattern funcExprPattern = Pattern.compile("function\\s*(?:\\w+)?\\s*\\(([^)]*)\\)\\s*\\{"); + Matcher m = funcExprPattern.matcher(text); + + while (m.find()) { + int start = m.start(); + if (isExcluded(start)) continue; + + // Check if this is a function expression (not a declaration) + // A function declaration is at statement level; expression is inside parens, after =, etc. + if (isFunctionDeclaration(start)) continue; + + int headerStart = start; + int headerEnd = m.end() - 1; // Position of '{' + int bodyStart = m.end() - 1; + int bodyEnd = findMatchingBrace(bodyStart); + if (bodyEnd < 0) bodyEnd = text.length(); + else bodyEnd++; // Include closing brace + + InnerCallableScope scope = new InnerCallableScope( + InnerCallableScope.Kind.JS_FUNCTION_EXPR, + headerStart, + headerEnd, + bodyStart, + bodyEnd + ); + + // Parse parameters + String paramsText = m.group(1).trim(); + if (!paramsText.isEmpty()) { + String[] params = paramsText.split(","); + int offset = m.start(1); + for (String param : params) { + param = param.trim(); + if (param.isEmpty()) continue; + + int nameOffset = text.indexOf(param, offset); + if (nameOffset < 0) nameOffset = offset; + + // JS params don't have types by default; will infer from expected FI + FieldInfo paramInfo = FieldInfo.parameter(param, TypeInfo.ANY, nameOffset, null); + scope.addParameter(paramInfo); + + offset = nameOffset + param.length(); + } + } + + innerScopes.add(scope); + } + } + + private boolean isFunctionDeclaration(int funcStart) { + // A function declaration is a statement, check context: + // - If preceded by = or ( or , or : or [ it's an expression + // - If at line start (possibly with whitespace) or after { or ; it's a declaration + + int pos = funcStart - 1; + while (pos >= 0 && Character.isWhitespace(text.charAt(pos)) && text.charAt(pos) != '\n') { + pos--; + } + + if (pos < 0) return true; // Start of file = declaration + + char prev = text.charAt(pos); + // These characters indicate it's an expression, not a declaration + if (prev == '=' || prev == '(' || prev == ',' || prev == ':' || prev == '[' || prev == '?') { + return false; + } + // After newline + whitespace could be declaration + if (prev == '\n' || prev == '{' || prev == ';' || prev == '}') { + return true; + } + + return true; // Default to declaration to avoid false positives in inner scopes + } + + private void setupScopeParents() { + // For each scope, find its immediate parent (smallest enclosing scope) + for (int i = 0; i < innerScopes.size(); i++) { + InnerCallableScope scope = innerScopes.get(i); + InnerCallableScope parent = null; + + for (int j = 0; j < innerScopes.size(); j++) { + if (i == j) continue; + InnerCallableScope candidate = innerScopes.get(j); + + // Check if candidate contains scope + if (candidate.getBodyStart() < scope.getHeaderStart() && + candidate.getBodyEnd() > scope.getFullEnd()) { + // candidate contains scope + if (parent == null || + (candidate.getBodyStart() > parent.getBodyStart())) { + // candidate is smaller (more immediate) than current parent + parent = candidate; + } + } + } + + scope.setParentScope(parent); + } + } + + /** + * Parse global fields/variables (outside methods) - UNIFIED for both Java and JavaScript. + * Stores results in the shared 'globalFields' map. + * + * For Java: Parses "[modifiers] Type fieldName [= expr];" + * For JavaScript: Parses "var/let/const varName [= expr];" outside of functions + */ + private void parseGlobalFields() { + if (isJavaScript()) { + // JavaScript: var/let/const varName = expr; (outside functions) + Pattern varPattern = Pattern.compile("(?:var|let|const)\\s+(\\w+)(?:\\s*(=)\\s*([^;\\n]+))?"); + Matcher m = varPattern.matcher(text); + + while (m.find()) { + int position = m.start(1); + if (isExcluded(position)) continue; + + // Check if inside a function - if so, skip (handled by parseLocalVariables) + boolean insideMethod = false; + for (MethodInfo method : getAllMethods()) { + if (method.containsPosition(position)) { + insideMethod = true; + break; + } + } + if (insideMethod) continue; + + String varName = m.group(1); + String initializer = m.group(3); + + // First, check for JSDoc type annotation + String documentation = extractDocumentationBefore(m.start()); + JSDocInfo jsDoc = jsDocParser.extractJSDocBefore(text, m.start()); + + TypeInfo typeInfo = null; + + // Priority 1: JSDoc @type takes precedence + if (jsDoc != null && jsDoc.hasTypeTag()) { + typeInfo = jsDoc.getDeclaredType(); + } + + // Priority 2: Infer from initializer if no JSDoc type + if (typeInfo == null && initializer != null && !initializer.trim().isEmpty()) { + typeInfo = resolveExpressionType(initializer.trim(), m.start(3)); + } + + // Priority 3: Use "any" type for uninitialized variables + if (typeInfo == null) { + typeInfo = TypeInfo.ANY; + } + + int initStart = -1, initEnd = -1; + if (m.group(2) != null) { + // Include the = sign in initStart + initStart = m.start(2); + initEnd = m.end(3); + } + + FieldInfo fieldInfo = FieldInfo.globalField(varName, typeInfo, position, documentation, initStart, initEnd, 0); + + if(jsDoc != null) + fieldInfo.setJSDocInfo(jsDoc); + + if (globalFields.containsKey(varName)) { + AssignmentInfo dupError = AssignmentInfo.duplicateDeclaration( + varName, position, position + varName.length(), + "Variable '" + varName + "' is already defined in the scope"); + declarationErrors.add(dupError); + } else { + globalFields.put(varName, fieldInfo); + } + } + } else { + // Java: [modifiers] Type fieldName [= expr]; + Matcher m = FIELD_DECL_PATTERN.matcher(text); + while (m.find()) { + String typeNameRaw = m.group(1); + String fieldName = m.group(2); + String delimiter = m.group(3); + int position = m.start(2); + + if (isExcluded(position)) + continue; + + int modifiers = parseModifiers(typeNameRaw); + String typeName = stripModifiers(typeNameRaw); + + ScriptTypeInfo containingScriptType = null; + for (ScriptTypeInfo scriptType : scriptTypes.values()) + if (scriptType.containsPosition(position)) { + containingScriptType = scriptType; + break; + } + + // Check if inside a method - if so, it's a local, not global + boolean insideMethod = false; + if (containingScriptType != null && isInsideNestedMethod(position, containingScriptType.getBodyStart(), + containingScriptType.getBodyEnd())) + insideMethod = true; + else { + for (MethodInfo method : getAllMethods()) { + if (method.containsPosition(position)) { + insideMethod = true; + break; + } + } + } + + if (insideMethod) + continue; + + String documentation = extractDocumentationBefore(m.start()); + + int initStart = -1; + int initEnd = -1; + if ("=".equals(delimiter)) { + initStart = m.start(3); + int searchPos = m.end(3); + int depth = 0; + while (searchPos < text.length()) { + char c = text.charAt(searchPos); + if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') depth--; + else if (c == ';' && depth == 0) { + initEnd = searchPos; + break; + } + searchPos++; + } + } + + TypeInfo typeInfo = resolveType(typeName); + FieldInfo fieldInfo = FieldInfo.globalField(fieldName, typeInfo, position, documentation, initStart, initEnd, modifiers); + + if (containingScriptType != null) { + containingScriptType.addField(fieldInfo); + } else if (globalFields.containsKey(fieldName)) { + AssignmentInfo dupError = AssignmentInfo.duplicateDeclaration( + fieldName, position, position + fieldName.length(), + "Variable '" + fieldName + "' is already defined in the scope"); + declarationErrors.add(dupError); + } else { + globalFields.put(fieldName, fieldInfo); + } + } + } + } + + /** + * Strip Java modifiers from a type name string. + * e.g., "public static String" -> "String" + */ + private String stripModifiers(String typeName) { + if (typeName == null) return null; + + String[] parts = typeName.trim().split("\\s+"); + // Filter out known modifiers + StringBuilder result = new StringBuilder(); + for (String part : parts) { + if (!TypeResolver.isModifier(part)) { + if (result.length() > 0) result.append(" "); + result.append(part); + } + } + return result.toString(); + } + + /** + * Parse modifiers from a declaration string and return the corresponding Modifier flags. + * e.g., "public static final" -> Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL + */ + private int parseModifiers(String declaration) { + if (declaration == null) return 0; + + int modifiers = 0; + String[] parts = declaration.trim().split("\\s+"); + for (String part : parts) { + if (part.equals("public")) modifiers |= java.lang.reflect.Modifier.PUBLIC; + else if (part.equals("private")) modifiers |= java.lang.reflect.Modifier.PRIVATE; + else if (part.equals("protected")) modifiers |= java.lang.reflect.Modifier.PROTECTED; + else if (part.equals("static")) modifiers |= java.lang.reflect.Modifier.STATIC; + else if (part.equals("final")) modifiers |= java.lang.reflect.Modifier.FINAL; + else if (part.equals("abstract")) modifiers |= java.lang.reflect.Modifier.ABSTRACT; + else if (part.equals("synchronized")) modifiers |= java.lang.reflect.Modifier.SYNCHRONIZED; + else if (part.equals("volatile")) modifiers |= java.lang.reflect.Modifier.VOLATILE; + else if (part.equals("transient")) modifiers |= java.lang.reflect.Modifier.TRANSIENT; + else if (part.equals("native")) modifiers |= java.lang.reflect.Modifier.NATIVE; + else if (part.equals("strictfp")) modifiers |= java.lang.reflect.Modifier.STRICT; + } + return modifiers; + } + + /** + * Scan backwards from scanStart position in the given text to extract modifier keywords. + * Returns the parsed modifier flags. + * + * @param scanStart The position to start scanning backwards from (exclusive) + * @param sourceText The text to scan in + * @return The combined modifier flags + */ + private int extractModifiersBackwards(int scanStart, String sourceText) { + // Skip whitespace + while (scanStart >= 0 && Character.isWhitespace(sourceText.charAt(scanStart))) { + scanStart--; + } + + // Scan backwards collecting modifier words + StringBuilder modifiersText = new StringBuilder(); + while (scanStart >= 0) { + // Skip whitespace + while (scanStart >= 0 && Character.isWhitespace(sourceText.charAt(scanStart))) { + scanStart--; + } + if (scanStart < 0) break; + + // Read a word backwards + int wordEnd = scanStart + 1; + while (scanStart >= 0 && Character.isJavaIdentifierPart(sourceText.charAt(scanStart))) { + scanStart--; + } + int wordStart = scanStart + 1; + if (wordStart < wordEnd) { + String word = sourceText.substring(wordStart, wordEnd); + // Check if it's a modifier + if (TypeResolver.isModifier(word)) { + if (modifiersText.length() > 0) { + modifiersText.insert(0, " "); + } + modifiersText.insert(0, word); + } else { + // Hit a non-modifier word, stop scanning + break; + } + } else { + break; + } + } + + // Parse the collected modifiers + return parseModifiers(modifiersText.toString()); + } + + /** + * Extract documentation comment immediately preceding a position. + * Supports both single-line (//) and multi-line (/* *\/) comment styles. + * Returns null if no documentation is found. + * + * @param position The position of the declaration (e.g., start of method/field) + * @return The documentation text, or null if none found + */ + private String extractDocumentationBefore(int position) { + if (position <= 0) return null; + + // First, skip backwards to get off the current declaration line + // (declarations might have modifiers like 'public' before the matched position) + int searchStart = position - 1; + + // Skip backwards to the previous newline + while (searchStart >= 0 && text.charAt(searchStart) != '\n') { + searchStart--; + } + + if (searchStart < 0) return null; + + // Now we're at a newline before the declaration line + // Skip backwards through any additional whitespace + searchStart--; + while (searchStart >= 0 && Character.isWhitespace(text.charAt(searchStart))) { + searchStart--; + } + + if (searchStart < 0) return null; + + // Now searchStart should be at the last non-whitespace character before the declaration line + + // Check if we're at the end of a comment + // Case 1: Multi-line comment ending with */ + if (searchStart > 0 && text.charAt(searchStart) == '/' && text.charAt(searchStart - 1) == '*') { + // Find the start of this comment + int commentEnd = searchStart + 1; + int commentStart = text.lastIndexOf("/*", searchStart - 1); + if (commentStart >= 0) { + String comment = text.substring(commentStart, commentEnd); + return cleanDocumentation(comment); + } + } + + // Case 2: Single-line comments (may be multiple consecutive lines) + StringBuilder doc = new StringBuilder(); + int currentPos = searchStart; + + // Walk backward through consecutive comment lines + while (currentPos >= 0) { + // Find start of current line + int lineStart = currentPos; + while (lineStart > 0 && text.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of current line + int lineEnd = currentPos; + while (lineEnd < text.length() && text.charAt(lineEnd) != '\n') { + lineEnd++; + } + + // Extract the line and trim + String line = text.substring(lineStart, lineEnd).trim(); + + // Check if this line is a comment + if (line.startsWith("//") || line.startsWith("#")) { + // Prepend to doc (we're going backwards) + String commentText = line.substring(2).trim(); + if (doc.length() > 0) { + doc.insert(0, "\n"); + } + doc.insert(0, commentText); + + // Move to previous line + if (lineStart > 0) { + currentPos = lineStart - 1; + // Skip to previous line (skip the newline) + while (currentPos >= 0 && text.charAt(currentPos) == '\n') { + currentPos--; + } + } else { + break; + } + } else { + // Not a comment line, stop + break; + } + } + + if (doc.length() > 0) { + return doc.toString(); + } + + return null; + } + + /** + * Clean up documentation comment text by removing comment markers and extra formatting. + */ + private String cleanDocumentation(String comment) { + if (comment == null || comment.isEmpty()) return null; + + // Remove /* and */ markers + comment = comment.replaceAll("^/\\*+\\s*", "").replaceAll("\\s*\\*+/$", ""); + + // Split into lines and clean each one + String[] lines = comment.split("\n"); + StringBuilder cleaned = new StringBuilder(); + + for (String line : lines) { + // Remove leading asterisks and whitespace + line = line.trim().replaceAll("^\\*+\\s*", ""); + if (cleaned.length() > 0 && !line.isEmpty()) { + cleaned.append("\n"); + } + if (!line.isEmpty()) { + cleaned.append(line); + } + } + + String result = cleaned.toString().trim(); + return result.isEmpty() ? null : result; + } + + public TypeInfo resolveType(String typeName) { + /** + * Resolve a JS type name to unified TypeInfo. + * Handles JS primitives, .d.ts defined types, and falls back to Java types. + */ + if (isJavaScript()) + return typeResolver.resolveJSType(typeName); + + return resolveTypeAndTrackUsage(typeName); + } + + /** + * Resolve a type and track the import usage for unused import detection. + * Checks script-defined types first, then falls back to imported types. + * Used for Java/Groovy scripts. + */ + private TypeInfo resolveTypeAndTrackUsage(String typeName) { + if (typeName == null || typeName.isEmpty()) + return TypeInfo.unresolved(typeName, typeName); + + final String normalized = typeName.trim(); + final String normalizedFinal = stripLeadingModifiers(normalized); + + // Split array suffixes first + TypeStringNormalizer.ArraySplit arraySplit = TypeStringNormalizer.splitArraySuffixes(normalizedFinal); + String baseExpr = arraySplit.base; + int arrayDims = arraySplit.dimensions; + + // Base type resolver with import tracking + Function resolveBase = baseName -> { + TypeInfo resolved; + + // Primitives + if (TypeResolver.isPrimitiveType(baseName)) { + resolved = TypeInfo.fromPrimitive(baseName); + } + // Common java.lang.String + else if ("String".equals(baseName)) { + resolved = typeResolver.resolveFullName("java.lang.String"); + } + // Script-defined types (simple names only) + else if (!baseName.contains(".") && scriptTypes.containsKey(baseName)) { + resolved = scriptTypes.get(baseName); + } + // Fully-qualified + else if (baseName.contains(".")) { + resolved = typeResolver.resolveFullName(baseName); + } + // Imported/simple + else { + resolved = typeResolver.resolveSimpleName(baseName, importsBySimpleName, wildcardPackages); + + // Track import usage + if (resolved != null && resolved.isResolved()) { + ImportData usedImport = importsBySimpleName.get(baseName); + if (usedImport != null) { + usedImport.incrementUsage(); + } else if (wildcardPackages != null) { + String resultPkg = resolved.getPackageName(); + for (ImportData imp : imports) { + if (imp.isWildcard() && resultPkg != null && resultPkg.equals(imp.getFullPath())) { + imp.incrementUsage(); + break; + } + } + } + } + } + + return resolved != null ? resolved : TypeInfo.unresolved(baseName, normalizedFinal); + }; + + TypeInfo resolved; + + // Fast path: no generics - skip expensive parsing + if (!baseExpr.contains("<")) { + resolved = resolveBase.apply(baseExpr); + } else { + // Slow path: parse and resolve generics + GenericTypeParser.ParsedType parsed = GenericTypeParser.parse(baseExpr); + if (parsed != null) { + // Normalize whitespace around dots in base name + String baseName = parsed.baseName.replaceAll("\\s*\\.\\s*", ".").trim(); + resolved = resolveBase.apply(baseName); + + // Apply generic arguments + if (parsed.hasTypeArgs() && resolved != null && resolved.isResolved()) { + List resolvedArgs = new ArrayList<>(); + for (GenericTypeParser.ParsedType argParsed : parsed.typeArgs) { + if (argParsed == null) { + resolvedArgs.add(TypeInfo.fromClass(Object.class)); + } else { + TypeInfo argType = resolveTypeAndTrackUsage(argParsed.rawString); + resolvedArgs.add(argType != null ? argType : TypeInfo.unresolved(argParsed.baseName, + argParsed.baseName)); + } + } + if (!resolvedArgs.isEmpty()) { + resolved = resolved.parameterize(resolvedArgs); + } + } + } else + // Fallback: treat as simple type + resolved = resolveBase.apply(baseExpr); + } + + // Apply array dimensions + for (int i = 0; i < arrayDims; i++) { + resolved = TypeInfo.arrayOf(resolved); + } + + return resolved; + } + + /** + * Strip only LEADING Java modifiers from a type expression. + * This avoids breaking generic argument lists which may contain whitespace. + */ + private String stripLeadingModifiers(String typeExpr) { + if (typeExpr == null) { + return null; + } + int i = 0; + int len = typeExpr.length(); + while (i < len) { + while (i < len && Character.isWhitespace(typeExpr.charAt(i))) i++; + int start = i; + while (i < len && Character.isJavaIdentifierPart(typeExpr.charAt(i))) i++; + if (start == i) { + break; + } + String word = typeExpr.substring(start, i); + if (TypeResolver.isModifier(word)) { + // Continue stripping modifiers + continue; + } + // Not a modifier; rewind to the start of this token + return typeExpr.substring(start).trim(); + } + return typeExpr.trim(); + } + + // ==================== PHASE 4: BUILD MARKS ==================== + + /** + * Build syntax highlighting marks - UNIFIED for both Java and JavaScript. + * Uses the SAME mark methods for both languages since they share data structures. + */ + private List buildMarks() { + List marks = new ArrayList<>(); + + // Strings first to protect their content + addPatternMarks(marks, STRING_PATTERN, TokenType.STRING); + + // JSDoc comments with fragmented marking (avoids conflicts with @tags and {Type}) + markJSDocElements(marks); + + // Regular comments (non-JSDoc) + markNonJSDocComments(marks); + + // Keywords - same for both languages (KEYWORD_PATTERN includes JS keywords like function, var, let, const) + addPatternMarks(marks, KEYWORD_PATTERN, TokenType.KEYWORD); + + // Numbers - same for both languages + addPatternMarks(marks, NUMBER_PATTERN, TokenType.LITERAL); + + // Import statements - Java only + if (!isJavaScript()) { + markImports(marks); + } + + // Class/interface/enum declarations - Java only + if (!isJavaScript()) { + markClassDeclarations(marks); + markEnumConstants(marks); + } + + // Modifiers - Java only + if (!isJavaScript()) { + addPatternMarks(marks, MODIFIER_PATTERN, TokenType.MODIFIER); + } + + // Type declarations and usages - Java only (JS doesn't have explicit types) + if (!isJavaScript()) { + markTypeDeclarations(marks); + } + + // Methods/functions - UNIFIED (uses shared 'methods' list) + markMethodDeclarations(marks); + + // Method calls - UNIFIED (stores in shared 'methodCalls' list) + markMethodCalls(marks); + + // Variables and fields - UNIFIED (uses shared globalFields, methodLocals) + markVariables(marks); + + // Chained field accesses - UNIFIED (uses resolveVariable which handles both) + markChainedFieldAccesses(marks); + markImportedClassUsages(marks); + + // Java-specific final passes + if (!isJavaScript()) { + markCastTypes(marks); + markUnusedImports(marks); + // Mark method reference expressions (target::methodName) + markMethodReferences(marks); + } + + // Mark lambda and method reference operators (-> and ::) + markLambdaOperators(marks); + + // Mark lambda/function parameters with type info + markInnerScopeParameters(marks); + + // Validate lambda return types + validateLambdaReturnTypes(marks); + + // Final pass: Mark any remaining unmarked identifiers as undefined + markUndefinedIdentifiers(marks); + + return marks; + } + + /** + * Mark lambda arrow (->) and method reference (::) operators. + */ + private void markLambdaOperators(List marks) { + // Mark -> operators (lambda arrows) + for (int i = 0; i < text.length() - 1; i++) { + if (text.charAt(i) == '-' && text.charAt(i + 1) == '>') { + if (!isExcluded(i)) { + marks.add(new ScriptLine.Mark(i, i + 2, TokenType.KEYWORD)); + } + } + } + + // Mark :: operators (method references) + for (int i = 0; i < text.length() - 1; i++) { + if (text.charAt(i) == ':' && text.charAt(i + 1) == ':') { + if (!isExcluded(i)) { + marks.add(new ScriptLine.Mark(i, i + 2, TokenType.DEFAULT)); + } + } + } + } + + /** + * Pattern for method reference: target::methodName + * target can be: identifier, qualified name (a.b.c), or 'this'/'super' + */ + private static final java.util.regex.Pattern METHOD_REF_PATTERN = + java.util.regex.Pattern.compile("([a-zA-Z_$][a-zA-Z0-9_$]*(?:\\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\\s*::\\s*([a-zA-Z_$][a-zA-Z0-9_$]*|new)"); + + /** + * Mark method reference expressions with appropriate token types. + * Target gets its resolved type's token, method name gets METHOD_CALL if valid. + * Validates that the method exists on the target type. + */ + private void markMethodReferences(List marks) { + java.util.regex.Matcher m = METHOD_REF_PATTERN.matcher(text); + while (m.find()) { + int targetStart = m.start(1); + int targetEnd = m.end(1); + int methodStart = m.start(2); + int methodEnd = m.end(2); + + // Skip if in excluded region (string/comment) + if (isExcluded(targetStart) || isExcluded(methodStart)) { + continue; + } + + String target = m.group(1); + String methodName = m.group(2); + + // Check if there are parentheses after the method name (invalid for method references) + if (methodEnd < text.length() && text.charAt(methodEnd) == '(') { + // Mark the :: as error + int doubleColonPos = targetEnd; + while (doubleColonPos < methodStart && text.charAt(doubleColonPos) != ':') { + doubleColonPos++; + } + if (doubleColonPos < methodStart) { + marks.add(new ScriptLine.Mark(doubleColonPos, doubleColonPos + 2, TokenType.UNDEFINED_VAR, + TokenErrorMessage.from("Method references cannot have parentheses. Use '" + target + "::" + methodName + "' instead of '" + target + "::" + methodName + "()'"))); + } + // Also mark the method name as error + marks.add(new ScriptLine.Mark(methodStart, methodEnd, TokenType.UNDEFINED_VAR, + TokenErrorMessage.from("Method references cannot have parentheses"))); + // Mark opening paren as error too + marks.add(new ScriptLine.Mark(methodEnd, methodEnd + 1, TokenType.UNDEFINED_VAR, + TokenErrorMessage.from("Remove parentheses from method reference"))); + continue; // Skip normal processing for this invalid reference + } + + // Mark the target based on what it resolves to + markMethodRefTarget(marks, target, targetStart, targetEnd); + + // Resolve the target type to validate method existence + TypeInfo targetType = resolveMethodRefTargetType(target, targetStart); + + if (targetType != null && targetType.isResolved()) { + // Handle constructor references (::new) + if ("new".equals(methodName)) { + if (targetType.hasConstructors()) { + MethodInfo ctorInfo = targetType.findConstructor(0); + if (ctorInfo == null) { + List ctors = targetType.getConstructors(); + if (!ctors.isEmpty()) { + ctorInfo = ctors.get(0); + } + } + marks.add(new ScriptLine.Mark(methodStart, methodEnd, TokenType.METHOD_CALL, ctorInfo)); + } else { + // No constructors found + marks.add(new ScriptLine.Mark(methodStart, methodEnd, TokenType.UNDEFINED_VAR, + TokenErrorMessage.from("No constructor found for '" + targetType.getSimpleName() + "'"))); + } + } else if (targetType.hasMethod(methodName)) { + // Method exists - get the MethodInfo for metadata + MethodInfo methodInfo = targetType.getMethodInfo(methodName); + if (methodInfo == null) { + // Try getting from overloads if single getMethodInfo fails + List overloads = targetType.getAllMethodOverloads(methodName); + if (!overloads.isEmpty()) { + methodInfo = overloads.get(0); + } + } + marks.add(new ScriptLine.Mark(methodStart, methodEnd, TokenType.METHOD_CALL, methodInfo)); + } else { + // Method does not exist on target type + marks.add(new ScriptLine.Mark(methodStart, methodEnd, TokenType.UNDEFINED_VAR, + TokenErrorMessage.from("Method '" + methodName + "' not found in '" + targetType.getSimpleName() + "'"))); + } + } else { + // Could not resolve target type - mark method as potentially valid (no error) + // This allows for cases where the target type cannot be resolved but might be valid at runtime + marks.add(new ScriptLine.Mark(methodStart, methodEnd, TokenType.UNDEFINED_VAR)); + } + } + } + + /** + * Resolve the target type for a method reference expression. + * @param target The target expression (e.g., "this", "String", "java.util.Arrays") + * @param position The position in the text for scope resolution + * @return The resolved TypeInfo, or null if it cannot be resolved + */ + private TypeInfo resolveMethodRefTargetType(String target, int position) { + // Handle keywords + if ("this".equals(target)) { + // Resolve 'this' to the enclosing type + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null) { + return enclosingType; + } + // For hook methods, resolve to the implied 'this' type + MethodInfo containingMethod = findContainingMethod(position); + if (containingMethod != null && containingMethod.getContainingType() != null) { + return containingMethod.getContainingType(); + } + return null; + } + + if ("super".equals(target)) { + // Resolve 'super' to the parent type of the enclosing class + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null && enclosingType.hasSuperClass()) { + return enclosingType.getSuperClass(); + } + return null; + } + + // Handle qualified names (a.b.ClassName) + if (target.contains(".")) { + TypeInfo typeInfo = resolveType(target); + if (typeInfo != null && typeInfo.isResolved()) { + return typeInfo; + } + // Could not resolve as type - leave unresolved + return null; + } + + // Simple identifier - try as variable first (instance reference like myList::add) + FieldInfo varInfo = resolveVariable(target, position); + if (varInfo != null && varInfo.getTypeInfo() != null) { + return varInfo.getTypeInfo(); + } + + // Try as type name (class reference like String::valueOf) + TypeInfo typeInfo = resolveType(target); + if (typeInfo != null && typeInfo.isResolved()) { + return typeInfo; + } + + return null; + } + + /** + * Mark the target of a method reference with the appropriate token type. + */ + private void markMethodRefTarget(List marks, String target, int start, int end) { + // Handle keywords + if ("this".equals(target)) { + marks.add(new ScriptLine.Mark(start, end, TokenType.KEYWORD)); + return; + } + if ("super".equals(target)) { + marks.add(new ScriptLine.Mark(start, end, TokenType.KEYWORD)); + return; + } + + // Handle qualified names (a.b.ClassName) - mark the whole thing + if (target.contains(".")) { + // Try to resolve as a type + TypeInfo typeInfo = resolveType(target); + if (typeInfo != null && typeInfo.isResolved()) { + marks.add(new ScriptLine.Mark(start, end, TokenType.IMPORTED_CLASS, typeInfo)); + return; + } + // Otherwise mark the parts separately + markQualifiedTargetParts(marks, target, start); + return; + } + + // Simple identifier - determine its token type + // Check for local variable + FieldInfo varInfo = resolveVariable(target, start); + if (varInfo != null) { + TokenType tokenType = varInfo.isParameter() ? TokenType.PARAMETER + : varInfo.isGlobal() ? TokenType.GLOBAL_FIELD + : TokenType.LOCAL_FIELD; + marks.add(new ScriptLine.Mark(start, end, tokenType, varInfo)); + return; + } + + // Check for type name (imported class) + TypeInfo typeInfo = resolveType(target); + if (typeInfo != null && typeInfo.isResolved()) { + marks.add(new ScriptLine.Mark(start, end, TokenType.IMPORTED_CLASS, typeInfo)); + return; + } + + // Unknown - leave as default + } + + /** + * Mark parts of a qualified name like java.util.Arrays::asList + */ + private void markQualifiedTargetParts(List marks, String qualifiedName, int baseStart) { + String[] parts = qualifiedName.split("\\."); + int offset = baseStart; + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + int partEnd = offset + part.length(); + + if (i == parts.length - 1) { + // Last part is typically the class name + marks.add(new ScriptLine.Mark(offset, partEnd, TokenType.IMPORTED_CLASS)); + } else { + // Package parts + marks.add(new ScriptLine.Mark(offset, partEnd, TokenType.TYPE_DECL)); + } + + offset = partEnd + 1; // +1 for the dot + } + } + + /** + * Mark parameters in all inner callable scopes (lambdas and JS function expressions). + */ + private void markInnerScopeParameters(List marks) { + for (InnerCallableScope scope : innerScopes) { + for (FieldInfo param : scope.getParameters()) { + int start = param.getDeclarationOffset(); + int end = start + param.getName().length(); + + // Use FieldInfo as metadata - hover system will extract tooltip + marks.add(new ScriptLine.Mark(start, end, TokenType.PARAMETER, param)); + } + } + } + + /** + * Validate lambda return types against expected SAM return type. + */ + private void validateLambdaReturnTypes(List marks) { + for (InnerCallableScope scope : innerScopes) { + if (scope.getKind() == InnerCallableScope.Kind.JAVA_LAMBDA || + scope.getKind() == InnerCallableScope.Kind.JS_FUNCTION_EXPR) { + validateLambdaReturnType(scope, marks); + } + } + } + + /** + * Validate that a lambda's body return type matches the expected SAM return type. + */ + private void validateLambdaReturnType(InnerCallableScope lambda, List marks) { + TypeInfo expectedType = lambda.getExpectedType(); + if (expectedType == null || !expectedType.isFunctionalInterface()) { + return; // No expected type to validate against + } + + MethodInfo sam = expectedType.getSingleAbstractMethod(); + if (sam == null) { + return; + } + + TypeInfo expectedReturnType = sam.getReturnType(); + + int bodyStart = lambda.getBodyStart(); + int bodyEnd = lambda.getBodyEnd(); + if (bodyStart < 0 || bodyEnd <= bodyStart || bodyEnd > text.length()) { + return; + } + + String bodyText = text.substring(bodyStart, bodyEnd).trim(); + + if (bodyText.startsWith("{")) { + // Block lambda - validate return statements + validateBlockLambdaReturns(lambda, bodyStart, bodyEnd, expectedReturnType, marks); + } else { + // Expression lambda - validate expression type + try { + TypeInfo bodyType = resolveExpressionType(bodyText, bodyStart); + + if (bodyType != null && expectedReturnType != null) { + // Check compatibility + if (!isCompatibleType(bodyType, expectedReturnType)) { + // Mark error at body start + String error = "Incompatible return type: expected " + + expectedReturnType.getSimpleName() + + " but was " + bodyType.getSimpleName(); + marks.add(new ScriptLine.Mark(bodyStart, bodyEnd, TokenType.UNDEFINED_VAR, TokenErrorMessage.from(error))); + } + } + } catch (Exception e) { + // Fail soft - don't crash on malformed expressions + } + } + } + + /** + * Validate return statements in a block lambda (one with { }). + * Checks that all return statements return compatible types with the expected SAM return type. + */ + private void validateBlockLambdaReturns(InnerCallableScope lambda, int blockStart, int blockEnd, + TypeInfo expectedReturnType, List marks) { + // Find all return statements in the block + String blockText = text.substring(blockStart, blockEnd); + Pattern returnPattern = Pattern.compile("\\breturn\\b(\\s*;|\\s+[^;]+;)"); + Matcher m = returnPattern.matcher(blockText); + + while (m.find()) { + int returnStart = blockStart + m.start(); + int returnEnd = blockStart + m.end(); + + if (isExcluded(returnStart)) { + continue; + } + + // Get the return expression (if any) + String returnStmt = m.group(1).trim(); + + if (returnStmt.equals(";")) { + // return; - void return + if (expectedReturnType != null && !expectedReturnType.getSimpleName().equals("void")) { + String error = "Cannot return void from lambda expecting " + expectedReturnType.getSimpleName(); + marks.add(new ScriptLine.Mark(returnStart, returnEnd, TokenType.UNDEFINED_VAR, TokenErrorMessage.from(error))); + } + } else { + // return ; - typed return + String expr = returnStmt.substring(0, returnStmt.length() - 1).trim(); + + if (expectedReturnType != null && expectedReturnType.getSimpleName().equals("void")) { + String error = "Cannot return a value from void lambda"; + marks.add(new ScriptLine.Mark(returnStart, returnEnd, TokenType.UNDEFINED_VAR, TokenErrorMessage.from(error))); + } else { + try { + // Validate return expression type + int exprStart = returnStart + m.group(0).indexOf(expr); + TypeInfo returnType = resolveExpressionType(expr, exprStart); + + if (returnType != null && expectedReturnType != null) { + if (!isCompatibleType(returnType, expectedReturnType)) { + String error = "Incompatible return type: expected " + + expectedReturnType.getSimpleName() + + " but returned " + returnType.getSimpleName(); + int exprEnd = exprStart + expr.length(); + marks.add(new ScriptLine.Mark(exprStart, exprEnd, TokenType.UNDEFINED_VAR, TokenErrorMessage.from(error))); + } + } + } catch (Exception e) { + // Fail soft on malformed expressions + } + } + } + } + + // Check for missing returns in non-void lambdas + // This is a warning rather than an error - Java allows it if the lambda throws or has infinite loop + // We skip this validation to avoid false positives + } + + /** + * Check if an actual type is compatible with an expected type. + * Includes boxing, subtype checking, and special cases like void. + */ + private boolean isCompatibleType(TypeInfo actual, TypeInfo expected) { + if (actual == null || expected == null) return false; + if (actual.equals(expected)) return true; + if (actual.getSimpleName().equals(expected.getSimpleName())) return true; + + // void is compatible with anything (statement lambda) + if (expected.getSimpleName().equals("void")) return true; + + // Object is compatible with anything + if (expected.getSimpleName().equals("Object") || expected.getFullName().equals("java.lang.Object")) return true; + + // Check primitive boxing + if (isBoxingCompatible(actual.getSimpleName(), expected.getSimpleName())) return true; + + // Use TypeChecker for more complex compatibility + return TypeChecker.isTypeCompatible(expected, actual); + } + + /** + * Check if types are compatible via boxing/unboxing. + */ + private boolean isBoxingCompatible(String actualName, String expectedName) { + if (actualName.equals("int") && expectedName.equals("Integer")) return true; + if (actualName.equals("Integer") && expectedName.equals("int")) return true; + if (actualName.equals("boolean") && expectedName.equals("Boolean")) return true; + if (actualName.equals("Boolean") && expectedName.equals("boolean")) return true; + if (actualName.equals("long") && expectedName.equals("Long")) return true; + if (actualName.equals("Long") && expectedName.equals("long")) return true; + if (actualName.equals("double") && expectedName.equals("Double")) return true; + if (actualName.equals("Double") && expectedName.equals("double")) return true; + if (actualName.equals("float") && expectedName.equals("Float")) return true; + if (actualName.equals("Float") && expectedName.equals("float")) return true; + if (actualName.equals("byte") && expectedName.equals("Byte")) return true; + if (actualName.equals("Byte") && expectedName.equals("byte")) return true; + if (actualName.equals("short") && expectedName.equals("Short")) return true; + if (actualName.equals("Short") && expectedName.equals("short")) return true; + if (actualName.equals("char") && expectedName.equals("Character")) return true; + if (actualName.equals("Character") && expectedName.equals("char")) return true; + return false; + } + + /** + * Find and mark unused imports as UNUSED_IMPORT type. + * This must be called after all other mark building is complete. + */ + private void markUnusedImports(List marks) { + for (ImportData imp : imports) { + if (!imp.isUsed() && imp.isResolved() && !imp.isWildcard()) { + // This import is not used - find and update its marks + // Mark the entire import line as unused (gray color) + marks.add(new ScriptLine.Mark(imp.getStartOffset(), imp.getEndOffset(), + TokenType.UNUSED_IMPORT, imp)); + } + } + } + + /** + * Final pass: Mark any remaining unmarked identifiers as UNDEFINED_VAR. + * This should be called last, after all other marking passes are complete. + * Only marks identifiers that haven't been marked by any other pass. + */ + private void markUndefinedIdentifiers(List marks) { + // Build a set of all marked positions for fast lookup + // Use a boolean array for O(1) lookup + boolean[] markedPositions = new boolean[text.length()]; + for (ScriptLine.Mark mark : marks) { + for (int i = mark.start; i < mark.end && i < markedPositions.length; i++) { + markedPositions[i] = true; + } + } + + // Keywords that should not be marked as undefined + Set knownKeywords = new HashSet<>(Arrays.asList( + "boolean", "int", "float", "double", "long", "char", "byte", "short", "void", + "null", "true", "false", "if", "else", "switch", "case", "for", "while", "do", + "try", "catch", "finally", "return", "throw", "var", "let", "const", "function", + "continue", "break", "this", "new", "typeof", "instanceof", "class", "interface", + "extends", "implements", "import", "package", "public", "private", "protected", + "static", "final", "abstract", "synchronized", "native", "default", "enum", + "throws", "super", "assert", "volatile", "transient" + )); + + // Find all identifiers + Pattern identifier = Pattern.compile("\\b([a-zA-Z_][a-zA-Z0-9_]*)\\b"); + Matcher m = identifier.matcher(text); + + while (m.find()) { + int start = m.start(1); + int end = m.end(1); + String name = m.group(1); + + // Skip if already marked + if (start < markedPositions.length && markedPositions[start]) { + continue; + } + + // Skip if in excluded region (string/comment) + if (isExcluded(start)) { + continue; + } + + // Skip keywords + if (knownKeywords.contains(name)) { + continue; + } + + // Mark as undefined + marks.add(new ScriptLine.Mark(start, end, TokenType.UNDEFINED_VAR, null)); + } + } + + private void addPatternMarks(List marks, Pattern pattern, TokenType type) { + addPatternMarks(marks, pattern, type, 0); + } + + private void addPatternMarks(List marks, Pattern pattern, TokenType type, int group) { + Matcher m = pattern.matcher(text); + while (m.find()) { + marks.add(new ScriptLine.Mark(m.start(group), m.end(group), type)); + } + } + + private void markImports(List marks) { + for (ImportData imp : imports) { + // Skip implicit imports (from JaninoScript defaults, not in source text) + // These have offset -1 and don't need to be visually marked + if (imp.getStartOffset() < 0) + continue; + + // Mark 'import' keyword + marks.add(new ScriptLine.Mark(imp.getStartOffset(), imp.getStartOffset() + 6, TokenType.IMPORT_KEYWORD, imp)); + + // Parse path tokens + int pathStart = imp.getPathStartOffset(); + int pathEnd = imp.getPathEndOffset(); + String pathText = text.substring(pathStart, Math.min(pathEnd, text.length())); + + // Skip if path ends with dot (incomplete) + if (pathText.trim().endsWith(".")) { + continue; + } + + // Tokenize the path + List tokens = new ArrayList<>(); + List tokenStarts = new ArrayList<>(); + List tokenEnds = new ArrayList<>(); + Pattern idPattern = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + Matcher idm = idPattern.matcher(pathText); + while (idm.find()) { + tokens.add(idm.group()); + tokenStarts.add(pathStart + idm.start()); + tokenEnds.add(pathStart + idm.end()); + } + + if (tokens.isEmpty()) continue; + + if (imp.isWildcard()) { + // Wildcard import: mark all tokens as package (blue) if valid + boolean pkgValid = typeResolver.isValidPackage(imp.getFullPath()); + + // Also check if it might be a class wildcard (OuterClass.*) + TypeInfo outerType = typeResolver.resolveFullName(imp.getFullPath()); + + if (pkgValid || outerType != null) { + // Mark package portion in blue + int pkgTokenCount = outerType != null ? tokens.size() - 1 : tokens.size(); + for (int i = 0; i < Math.min(pkgTokenCount, tokens.size()); i++) { + marks.add(new ScriptLine.Mark(tokenStarts.get(i), tokenEnds.get(i), TokenType.TYPE_DECL, imp)); + } + // If it's a class wildcard, mark the class with its type + if (outerType != null && tokens.size() > 0) { + int lastIdx = tokens.size() - 1; + marks.add(new ScriptLine.Mark(tokenStarts.get(lastIdx), tokenEnds.get(lastIdx), + outerType.getTokenType(), imp)); + } + } else { + // Unknown package/class - mark as undefined + marks.add(new ScriptLine.Mark(pathStart, pathEnd, TokenType.UNDEFINED_VAR, imp)); + } + } else { + // Non-wildcard import: package is blue, class name(s) get type colors + TypeInfo resolvedType = imp.getResolvedType(); + + if (resolvedType != null && resolvedType.isResolved()) { + // Count package segments + String fullPath = imp.getFullPath(); + String resolvedName = resolvedType.getFullName(); + String pkgName = resolvedType.getPackageName(); + + int pkgSegments = pkgName != null && !pkgName.isEmpty() + ? pkgName.split("\\.").length : 0; + + // Mark package tokens in blue + for (int i = 0; i < Math.min(pkgSegments, tokens.size()); i++) { + marks.add(new ScriptLine.Mark(tokenStarts.get(i), tokenEnds.get(i), TokenType.TYPE_DECL, imp)); + } + + // Mark class tokens with appropriate type colors + // For inner classes, each segment might have a different type + for (int i = pkgSegments; i < tokens.size(); i++) { + String segmentName = tokens.get(i); + // Try to resolve this specific class/inner class + StringBuilder classPath = new StringBuilder(); + if (pkgName != null && !pkgName.isEmpty()) { + classPath.append(pkgName).append("."); + } + for (int j = pkgSegments; j <= i; j++) { + if (j > pkgSegments) classPath.append("$"); + classPath.append(tokens.get(j)); + } + + TypeInfo segmentType = typeResolver.resolveFullName(classPath.toString()); + TokenType tokenType = segmentType != null + ? segmentType.getTokenType() + : resolvedType.getTokenType(); + + marks.add(new ScriptLine.Mark(tokenStarts.get(i), tokenEnds.get(i), tokenType, imp)); + } + } else { + // Try to figure out which parts are valid vs invalid + // First, try to find valid package segments + int lastValidPkg = -1; + StringBuilder pkgBuilder = new StringBuilder(); + + for (int i = 0; i < tokens.size(); i++) { + if (i > 0) pkgBuilder.append("."); + pkgBuilder.append(tokens.get(i)); + + // Check if this is a valid package + if (typeResolver.isValidPackage(pkgBuilder.toString())) { + lastValidPkg = i; + } + } + + // Now try to resolve outer classes that might exist after the package + // For import like kamkeel.api.IOverlay.idk where IOverlay exists but idk doesn't + int lastValidClass = -1; + TypeInfo lastValidType = null; + StringBuilder classPath = new StringBuilder(); + + if (lastValidPkg >= 0) { + classPath.append(pkgBuilder.substring(0, + pkgBuilder.toString().indexOf(tokens.get(lastValidPkg)) + tokens.get(lastValidPkg) + .length())); + } + + for (int i = lastValidPkg + 1; i < tokens.size(); i++) { + if (classPath.length() > 0) + classPath.append(i == lastValidPkg + 1 ? "." : "$"); + classPath.append(tokens.get(i)); + + TypeInfo segmentType = typeResolver.resolveFullName(classPath.toString()); + if (segmentType != null && segmentType.isResolved()) { + lastValidClass = i; + lastValidType = segmentType; + } else { + break; // Stop at first unresolved segment + } + } + + // Mark valid package portion in blue + for (int i = 0; i <= lastValidPkg; i++) { + marks.add(new ScriptLine.Mark(tokenStarts.get(i), tokenEnds.get(i), TokenType.TYPE_DECL, imp)); + } + + // Mark valid class segments with their type colors + StringBuilder resolvedPath = new StringBuilder(); + if (lastValidPkg >= 0) { + for (int i = 0; i <= lastValidPkg; i++) { + if (i > 0) + resolvedPath.append("."); + resolvedPath.append(tokens.get(i)); + } + } + + for (int i = lastValidPkg + 1; i <= lastValidClass; i++) { + if (resolvedPath.length() > 0) + resolvedPath.append(i == lastValidPkg + 1 ? "." : "$"); + resolvedPath.append(tokens.get(i)); + + TypeInfo segmentType = typeResolver.resolveFullName(resolvedPath.toString()); + TokenType tokenType = (segmentType != null) ? segmentType.getTokenType() : TokenType.IMPORTED_CLASS; + marks.add(new ScriptLine.Mark(tokenStarts.get(i), tokenEnds.get(i), tokenType, imp)); + } + + // Mark remaining unresolved segments as undefined (red) + int firstUnresolved = Math.max(lastValidPkg + 1, lastValidClass + 1); + for (int i = firstUnresolved; i < tokens.size(); i++) { + marks.add(new ScriptLine.Mark(tokenStarts.get(i), tokenEnds.get(i), TokenType.UNDEFINED_VAR, + imp)); + } + + // If nothing was valid at all, mark everything as undefined + if (lastValidPkg < 0 && lastValidClass < 0) { + marks.add(new ScriptLine.Mark(pathStart, pathEnd, TokenType.UNDEFINED_VAR, imp)); + } + } + } + } + } + + private void markClassDeclarations(List marks) { + // Extended pattern to capture extends and implements clauses + // Groups: 1=class|interface|enum, 2=TypeName, 3=extends clause, 4=implements clause + Pattern classWithInheritance = Pattern.compile( + "\\b(class|interface|enum)\\s+([A-Za-z_][a-zA-Z0-9_]*)\\s*(?:\\(\\))?\\s*" + + "(?:(extends)\\s+([A-Za-z_][a-zA-Z0-9_.]*))?\\s*" + + "(?:(implements)\\s+([A-Za-z_][a-zA-Z0-9_.,\\s]*))?\\s*(?=\\{)"); + + Matcher m = classWithInheritance.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) + continue; + + // Mark the keyword (class/interface/enum) + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.CLASS_KEYWORD)); + + // Mark the class name + String kind = m.group(1); + TokenType nameType; + if ("interface".equals(kind)) { + nameType = TokenType.INTERFACE_DECL; + } else if ("enum".equals(kind)) { + nameType = TokenType.ENUM_DECL; + } else { + nameType = TokenType.CLASS_DECL; + } + String typeName = m.group(2); + marks.add(new ScriptLine.Mark(m.start(2), m.end(2), nameType, scriptTypes.get(typeName))); + + // Mark extends clause + if (m.group(3) != null) { + //Extends key word + marks.add(new ScriptLine.Mark(m.start(3), m.end(3), TokenType.IMPORT_KEYWORD)); + markExtendsClause(marks, m.start(4), m.group(4)); + } + + // Mark implements clause + if (m.group(5) != null) { + //Implements key word + marks.add(new ScriptLine.Mark(m.start(5),m.end(5), TokenType.IMPORT_KEYWORD)); + markImplementsClause(marks, m.start(6), m.group(6)); + } + } + } + + /** + * Mark enum constants with ENUM_CONSTANT token type. + * Adds marks for all enum constants in script-defined enums. + */ + private void markEnumConstants(List marks) { + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + if (!scriptType.isEnum()) + continue; + + // Mark each enum constant + for (EnumConstantInfo constant : scriptType.getEnumConstants().values()) { + FieldInfo fieldInfo = constant.getFieldInfo(); + int start = fieldInfo.getDeclarationOffset(); + int end = start + fieldInfo.getName().length(); + + // Always use ENUM_CONSTANT token type (blue + bold + italic) + // Errors are shown via underline, not by changing the token type + marks.add(new ScriptLine.Mark(start, end, TokenType.ENUM_CONSTANT, fieldInfo)); + } + } + } + + /** + * Mark the extends clause with proper coloring. + * The parent class is colored based on its resolved type. + */ + private void markExtendsClause(List marks, int clauseStart, String parentName) { + String trimmedName = parentName.trim(); + if (trimmedName.isEmpty()) + return; + + + // Resolve the parent type + TypeInfo parentType = resolveType(trimmedName); + TokenType tokenType; + + if (parentType != null && parentType.isResolved()) { + // Use the type's specific token type (CLASS, INTERFACE, ENUM) + tokenType = parentType.getTokenType(); + } else { + // Unresolved - mark as undefined + tokenType = TokenType.UNDEFINED_VAR; + if (parentType == null) { + parentType = TypeInfo.unresolved(trimmedName, trimmedName); + } + } + + // Find actual position in text (might have leading whitespace in the group) + int actualStart = clauseStart; + String fullClause = parentName; + while (actualStart < clauseStart + fullClause.length() && + Character.isWhitespace(text.charAt(actualStart))) { + actualStart++; + } + + marks.add(new ScriptLine.Mark(actualStart, actualStart + trimmedName.length(), tokenType, parentType)); + } + + /** + * Mark the implements clause with proper coloring. + * Each interface is colored based on its resolved type. + */ + private void markImplementsClause(List marks, int clauseStart, String implementsList) { + String[] interfaces = implementsList.split(","); + int currentPos = clauseStart; + + for (String ifaceName : interfaces) { + String trimmedName = ifaceName.trim(); + if (trimmedName.isEmpty()) { + currentPos += ifaceName.length() + 1; // +1 for comma + continue; + } + + // Find the actual start position of this interface name in the text + int leadingSpaces = 0; + while (leadingSpaces < ifaceName.length() && Character.isWhitespace(ifaceName.charAt(leadingSpaces))) { + leadingSpaces++; + } + int actualStart = currentPos + leadingSpaces; + + // Resolve the interface type + TypeInfo ifaceType = resolveType(trimmedName); + TokenType tokenType; + + if (ifaceType != null && ifaceType.isResolved()) { + tokenType = ifaceType.getTokenType(); + } else { + tokenType = TokenType.UNDEFINED_VAR; + if (ifaceType == null) { + ifaceType = TypeInfo.unresolved(trimmedName, trimmedName); + } + } + + marks.add(new ScriptLine.Mark(actualStart, actualStart + trimmedName.length(), tokenType, ifaceType)); + + // Move past this interface name and the comma + currentPos += ifaceName.length() + 1; + } + } + + private void markTypeDeclarations(List marks) { + // First, mark variables that hold Java.type() results + if (isJavaScript()) { + markJavaTypeVariables(marks); + } + + // Pattern for type optionally followed by generics - we'll manually parse generics + Pattern typeStart = Pattern.compile( + "(?:(?:public|private|protected|static|final|transient|volatile)\\s+)*" + + "([a-zA-Z][a-zA-Z0-9_]*)\\s*"); + + Matcher m = typeStart.matcher(text); + int searchFrom = 0; + + while (m.find(searchFrom)) { + int typeNameStart = m.start(1); + int typeNameEnd = m.end(1); + + if (isExcluded(typeNameStart) || isInImportOrPackage(typeNameStart)) { + searchFrom = m.end(); + continue; + } + + String typeName = m.group(1); + int posAfterType = m.end(1); + + // Skip whitespace after type name + while (posAfterType < text.length() && Character.isWhitespace(text.charAt(posAfterType))) { + posAfterType++; + } + + // Check for generic parameters + String genericContent = null; + int genericStart = -1; + int genericEnd = -1; + + if (posAfterType < text.length() && text.charAt(posAfterType) == '<') { + genericStart = posAfterType; + int depth = 1; + int i = posAfterType + 1; + while (i < text.length() && depth > 0) { + char c = text.charAt(i); + if (c == '<') + depth++; + else if (c == '>') + depth--; + i++; + } + if (depth == 0) { + genericEnd = i; + genericContent = text.substring(genericStart + 1, genericEnd - 1); + posAfterType = genericEnd; + } + } + + // Skip whitespace after generics + while (posAfterType < text.length() && Character.isWhitespace(text.charAt(posAfterType))) { + posAfterType++; + } + + // Check if this looks like a type declaration: + boolean hasGeneric = genericContent != null && !genericContent.isEmpty(); + boolean followedByVarName = false; + boolean atEndOfLine = posAfterType >= text.length() || text.charAt(posAfterType) == '\n'; + + if (!atEndOfLine && posAfterType < text.length()) { + char nextChar = text.charAt(posAfterType); + followedByVarName = Character.isLetter(nextChar) || nextChar == '_'; + } + + // Accept as type if: has generics OR followed by variable name + if (hasGeneric || followedByVarName) { + // Resolve the main type + TypeInfo info = resolveType(typeName); + TokenType tokenType = (info != null && info.isResolved()) ? info.getTokenType() : TokenType.UNDEFINED_VAR; + marks.add(new ScriptLine.Mark(typeNameStart, typeNameEnd, tokenType, info)); + + // Handle generic content recursively + if (hasGeneric && genericStart >= 0) { + int contentStart = genericStart + 1; + markGenericTypesRecursive(genericContent, contentStart, marks); + } + } + + searchFrom = m.end(); + } + } + + + + /** + * Mark variables that hold Java.type() class references. + * Example: var File = Java.type("java.io.File"); + */ + private void markJavaTypeVariables(List marks) { + // Pattern: var/let/const varName = Java.type("className") + Pattern javaTypePattern = Pattern.compile( + "\\b(var|let|const)\\s+(\\w+)\\s*=\\s*Java\\.type\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)"); + + Matcher m = javaTypePattern.matcher(text); + + while (m.find()) { + if (isExcluded(m.start())) continue; + + String varName = m.group(2); + String className = m.group(3); + int varStart = m.start(2); + int varEnd = m.end(2); + + // Resolve the class name + TypeInfo classType = typeResolver.resolveFullName(className); + if (classType != null && classType.isResolved()) { + // Create ClassTypeInfo to represent that this variable holds a Class reference + ClassTypeInfo classRef = new ClassTypeInfo(classType); + + // Mark the variable with the ClassTypeInfo for hover info + marks.add(new ScriptLine.Mark(varStart, varEnd, TokenType.LOCAL_FIELD, classRef)); + } + } + } + + /** + * Recursively parse and mark generic type parameters. + * Handles arbitrarily nested generics like Map>>. + */ + private void markGenericTypesRecursive(String content, int baseOffset, List marks) { + if (content == null || content.isEmpty()) + return; + + int i = 0; + while (i < content.length()) { + char c = content.charAt(i); + + // Skip whitespace and punctuation + if (!Character.isJavaIdentifierStart(c)) { + i++; + continue; + } + + // Found start of identifier + int start = i; + while (i < content.length() && Character.isJavaIdentifierPart(content.charAt(i))) { + i++; + } + String typeName = content.substring(start, i); + + // Only process if it's an actual type (check via resolveType) + TypeInfo typeCheck = resolveType(typeName); + if (typeCheck != null && typeCheck.isResolved()) { + int absStart = baseOffset + start; + int absEnd = baseOffset + i; + + if (!isExcluded(absStart)) { + TypeInfo info = resolveType(typeName); + TokenType tokenType = (info != null && info.isResolved()) ? info.getTokenType() : TokenType.UNDEFINED_VAR; + marks.add(new ScriptLine.Mark(absStart, absEnd, tokenType, info)); + } + } + + // Skip whitespace + while (i < content.length() && Character.isWhitespace(content.charAt(i))) { + i++; + } + + // Check for nested generic + if (i < content.length() && content.charAt(i) == '<') { + int nestedStart = i + 1; + int depth = 1; + i++; + + // Find matching > + while (i < content.length() && depth > 0) { + if (content.charAt(i) == '<') + depth++; + else if (content.charAt(i) == '>') + depth--; + i++; + } + + // Recursively parse nested content + if (nestedStart < i - 1) { + String nestedContent = content.substring(nestedStart, i - 1); + markGenericTypesRecursive(nestedContent, baseOffset + nestedStart, marks); + } + } + } + } + + /** + * Mark JSDoc elements within comments for syntax highlighting. + * Adds marks for @tags (like @param, @return, @type) and {Type} references. + */ + private void markJSDocElements(List marks) { + // Find all JSDoc comments (/** ... */) + Pattern jsDocPattern = Pattern.compile("/\\*\\*([\\s\\S]*?)\\*/"); + Matcher jsDocMatcher = jsDocPattern.matcher(text); + + while (jsDocMatcher.find()) { + int commentStart = jsDocMatcher.start(); + int commentEnd = jsDocMatcher.end(); + + // Skip if this JSDoc is inside a string + boolean insideString = false; + for (ScriptLine.Mark mark : marks) { + if (mark.type == TokenType.STRING && + commentStart >= mark.start && commentEnd <= mark.end) { + insideString = true; + break; + } + } + if (insideString) { + continue; + } + + String commentContent = jsDocMatcher.group(0); + + // Find the method that this JSDoc belongs to, for parameter validation + MethodInfo associatedMethod = findMethodAfterPosition(commentEnd); + Set methodParamNames = new HashSet<>(); + if (associatedMethod != null) { + for (FieldInfo param : associatedMethod.getParameters()) { + methodParamNames.add(param.getName()); + } + } + + // Collect all special element positions (@tags and {Type}s) that should NOT be gray + java.util.List specialRanges = new java.util.ArrayList<>(); + + // Find @tags and process @param and @type specially + Pattern tagPattern = Pattern.compile("@(\\w+)"); + Matcher tagMatcher = tagPattern.matcher(commentContent); + while (tagMatcher.find()) { + int tagStart = commentStart + tagMatcher.start(); + int tagEnd = commentStart + tagMatcher.end(); + specialRanges.add(new int[]{tagStart, tagEnd}); + + String tagName = tagMatcher.group(1); + + // For @type tag, resolve the type and attach it for hover + TypeInfo tagTypeInfo = null; + if ("type".equals(tagName)) { + // Look for {Type} after @type + int afterTag = tagMatcher.end(); + String afterTagText = commentContent.substring(afterTag); + Pattern typeRefPattern = Pattern.compile("^\\s*\\{([^}]+)\\}"); + Matcher typeRefMatcher = typeRefPattern.matcher(afterTagText); + if (typeRefMatcher.find()) { + String typeName = typeRefMatcher.group(1).trim(); + tagTypeInfo = resolveType(typeName); + } + } + + // Mark the @tag itself (with TypeInfo for @type) + marks.add(new ScriptLine.Mark(tagStart, tagEnd, TokenType.JSDOC_TAG, tagTypeInfo)); + + // If this is @param, look for parameter name after the type + if ("param".equals(tagName)) { + // Look for: @param {Type} paramName or @param paramName + int afterTag = tagMatcher.end(); + String afterTagText = commentContent.substring(afterTag); + + // Pattern to match optional {Type} followed by parameter name + Pattern paramNamePattern = Pattern.compile("^\\s*(?:\\{[^}]*\\}\\s*)?([a-zA-Z_][a-zA-Z0-9_]*)"); + Matcher paramNameMatcher = paramNamePattern.matcher(afterTagText); + if (paramNameMatcher.find()) { + String paramName = paramNameMatcher.group(1); + int paramNameStart = commentStart + afterTag + paramNameMatcher.start(1); + int paramNameEnd = commentStart + afterTag + paramNameMatcher.end(1); + + // Check if this parameter exists in the method + boolean paramExists = methodParamNames.contains(paramName); + TokenType paramTokenType = paramExists ? TokenType.PARAMETER : TokenType.UNDEFINED_VAR; + + // Add to special ranges to exclude from comment marking + specialRanges.add(new int[]{paramNameStart, paramNameEnd}); + // Mark the parameter name + marks.add(new ScriptLine.Mark(paramNameStart, paramNameEnd, paramTokenType, null)); + } + } + } + + // Find {Type} references + Pattern typePattern = Pattern.compile("\\{([^}]+)\\}"); + Matcher typeMatcher = typePattern.matcher(commentContent); + while (typeMatcher.find()) { + int braceStart = commentStart + typeMatcher.start(); + int braceEnd = commentStart + typeMatcher.end(); + specialRanges.add(new int[]{braceStart, braceEnd}); + + // Mark braces + marks.add(new ScriptLine.Mark(braceStart, braceStart + 1, TokenType.JSDOC_TYPE, null)); // { + marks.add(new ScriptLine.Mark(braceEnd - 1, braceEnd, TokenType.JSDOC_TYPE, null)); // } + + // Mark the type name inside braces + String typeName = typeMatcher.group(1).trim(); + int typeStart = commentStart + typeMatcher.start(1); + int typeEnd = commentStart + typeMatcher.end(1); + + // Try to resolve the type for hover info + TypeInfo resolvedType = resolveType(typeName); + marks.add(new ScriptLine.Mark(typeStart, typeEnd, resolvedType != null? resolvedType.getTokenType() : TokenType.UNDEFINED_VAR, resolvedType)); + } + + // Sort special ranges by start position + specialRanges.sort((a, b) -> Integer.compare(a[0], b[0])); + + // Mark the comment in FRAGMENTS between special elements + int lastPos = commentStart; + for (int[] range : specialRanges) { + // Mark from lastPos to start of special element + if (lastPos < range[0]) { + marks.add(new ScriptLine.Mark(lastPos, range[0], TokenType.COMMENT, null)); + } + lastPos = range[1]; // Move past the special element + } + // Mark from last special element to end of comment + if (lastPos < commentEnd) { + marks.add(new ScriptLine.Mark(lastPos, commentEnd, TokenType.COMMENT, null)); + } + } + } + + /** + * Find the method declaration that immediately follows the given position. + * Used to associate JSDoc comments with their methods for parameter validation. + */ + private MethodInfo findMethodAfterPosition(int position) { + // Skip whitespace after position + int searchStart = position; + while (searchStart < text.length() && Character.isWhitespace(text.charAt(searchStart))) { + searchStart++; + } + + // Find the method with the smallest offset that is >= searchStart + MethodInfo closestMethod = null; + int closestDistance = Integer.MAX_VALUE; + + for (MethodInfo method : methods) { + if (!method.isDeclaration()) + continue; + int methodStart = method.getFullDeclarationOffset(); + if (methodStart < 0) + methodStart = method.getNameOffset(); + if (methodStart < 0) + continue; + + // Check if this method starts at or after our search position + // but within a reasonable distance (e.g., 200 chars to account for modifiers) + if (methodStart >= position && methodStart < position + 200) { + int distance = methodStart - position; + if (distance < closestDistance) { + closestDistance = distance; + closestMethod = method; + } + } + } + + // Also check methods inside script types + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + for (MethodInfo method : scriptType.getAllMethodsFlat()) { + if (!method.isDeclaration()) + continue; + int methodStart = method.getFullDeclarationOffset(); + if (methodStart < 0) + methodStart = method.getNameOffset(); + if (methodStart < 0) + continue; + + if (methodStart >= position && methodStart < position + 200) { + int distance = methodStart - position; + if (distance < closestDistance) { + closestDistance = distance; + closestMethod = method; + } + } + } + } + + return closestMethod; + } + + /** + * Mark all comments EXCEPT JSDoc comments (slash-star-star style). + * JSDoc comments are handled separately in markJSDocElements. + */ + private void markNonJSDocComments(List marks) { + // Match: /* ... */ (but not /**), // ..., # ... + Matcher m = COMMENT_PATTERN.matcher(text); + while (m.find()) { + int start = m.start(); + int end = m.end(); + String comment = m.group(); + + // Skip JSDoc comments (/** style) - they're handled separately + if (comment.startsWith("/**")) { + continue; + } + + marks.add(new ScriptLine.Mark(start, end, TokenType.COMMENT, null)); + } + } + + private void markMethodDeclarations(List marks) { + Matcher m = METHOD_DECL_PATTERN.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) + continue; + + // Skip class/interface/enum declarations - these look like method declarations + // but "class Foo(" is not a method, it's a class declaration + String returnType = m.group(1); + if (returnType.equals("class") || returnType.equals("interface") || returnType.equals("enum") ||returnType.equals("new")) { + continue; + } + + // Find the corresponding MethodInfo created in parseMethodDeclarations + int methodDeclStart = m.start(); + MethodInfo methodInfo = null; + for (MethodInfo method : getAllMethods()) { + if (method.getFullDeclarationOffset() == methodDeclStart) { + methodInfo = method; + break; + } + } + + // Return type + TokenType returnToken = TokenType.UNDEFINED_VAR; + if (methodInfo != null) + returnToken = methodInfo.getReturnType().getTokenType(); + + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), returnToken, + methodInfo != null ? methodInfo.getReturnType() : null)); + // Method name with MethodInfo metadata + marks.add(new ScriptLine.Mark(m.start(2), m.end(2), TokenType.METHOD_DECL, methodInfo)); + } + } + + private void markMethodCalls(List marks) { + Matcher m = METHOD_CALL_PATTERN.matcher(text); + while (m.find()) { + int nameStart = m.start(1); + int nameEnd = m.end(1); + String methodName = m.group(1); + + if (isExcluded(nameStart)) + continue; + + // Skip if in import/package statement + if (isInImportOrPackage(nameStart)) + continue; + + boolean skip = false; + for (MethodInfo decl : methods) + if (decl.getNameOffset() == nameStart) + // This is a method declaration, not a call + skip = true; + + if (skip) + continue; + + // Find the opening parenthesis by scanning forward from the method name end + // The regex includes \( but we scan manually to be safe + int openParen = nameEnd; + while (openParen < text.length() && Character.isWhitespace(text.charAt(openParen))) { + openParen++; + } + + if (openParen >= text.length() || text.charAt(openParen) != '(') { + // Not actually a method call + continue; + } + + // Find the matching closing parenthesis + int closeParen = findMatchingParen(openParen); + if (closeParen < 0) { + // Malformed - no closing paren + continue; + } + + // Handle super() constructor calls + if (methodName.equals("super")) { + handleSuperConstructorCall(marks, nameStart, nameEnd, openParen, closeParen); + continue; + } + + // Parse the arguments (first pass - without expected types for overload resolution) + List arguments = parseMethodArguments(openParen + 1, closeParen, null); + + // Check if this is a static access (Class.method() style) + boolean isStaticAccess = isStaticAccessCall(nameStart); + + // Resolve receiver using existing chain-based resolver and detect static access + TypeInfo receiverType = resolveReceiverChain(nameStart); + MethodInfo resolvedMethod = null; + + if (receiverType != null) { + // Check for synthetic types first (JavaScript only) + SyntheticType syntheticType = null; + if (isJavaScript()) { + syntheticType = typeResolver.getSyntheticType(receiverType.getSimpleName()); + } + + if (syntheticType != null && syntheticType.hasMethod(methodName)) { + // Synthetic type method call (e.g., Java.type()) + resolvedMethod = syntheticType.getMethodInfo(methodName); + + // Check for dynamic return type resolution (like Java.type("className")) + SyntheticMethod synMethod = syntheticType.getMethod(methodName); + TypeInfo dynamicReturnType = null; + if (synMethod != null) { + String argsText = text.substring(openParen + 1, closeParen); + String[] strArgs = TypeResolver.parseStringArguments(argsText); + dynamicReturnType = synMethod.resolveReturnType(strArgs); + } + + MethodCallInfo callInfo = new MethodCallInfo(methodName, nameStart, nameEnd, openParen, + closeParen, arguments, receiverType, resolvedMethod, false); + + // Set the actual resolved return type for downstream type resolution + if (dynamicReturnType != null) { + callInfo.setResolvedReturnType(dynamicReturnType); + } + + callInfo.validate(); + methodCalls.add(callInfo); + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_CALL, callInfo)); + continue; + } + // Regular type - check for method existence using hierarchy search if it's a ScriptTypeInfo + boolean hasMethod = false; + if (receiverType instanceof ScriptTypeInfo) { + hasMethod = ((ScriptTypeInfo) receiverType).hasMethodInHierarchy(methodName); + } else { + hasMethod = receiverType.hasMethod(methodName); + } + + if (hasMethod) { + + TypeInfo[] argTypes = arguments.stream().map(MethodCallInfo.Argument::getResolvedType).toArray(TypeInfo[]::new); + + // Get best method overload based on argument types + resolvedMethod = receiverType.getBestMethodOverload(methodName, argTypes); + + if (isStaticAccess && resolvedMethod != null && !resolvedMethod.isStatic()) { + TokenErrorMessage errorMsg = TokenErrorMessage + .from("Cannot call non-static method '" + methodName + "' from static context '" + receiverType.getSimpleName() + "'") + .clearOtherErrors(); + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.UNDEFINED_VAR, errorMsg)); + } else { + // Second pass: Re-resolve arguments with expected parameter types if method was found + if (resolvedMethod != null && resolvedMethod.getParameters().size() == arguments.size()) + arguments = parseMethodArguments(openParen + 1, closeParen, resolvedMethod); + + + MethodCallInfo callInfo = new MethodCallInfo(methodName, nameStart, nameEnd, openParen, + closeParen, arguments, receiverType, resolvedMethod, isStaticAccess); + // Set expected type for validation if this is the final expression + if (!isFollowedByDot(closeParen)) { + TypeInfo expectedType = findExpectedTypeAtPosition(nameStart); + if (expectedType != null) { + callInfo.setExpectedType(expectedType); + } + } + + callInfo.validate(); + methodCalls.add(callInfo); + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_CALL, callInfo)); + } + } else { + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.UNDEFINED_VAR)); + } + } else { + boolean hasDot = isPrecededByDot(nameStart); + if (hasDot) { + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.UNDEFINED_VAR)); + } else { + if (isScriptMethod(methodName)) { + // Extract argument types from parsed arguments + + TypeInfo[] argTypes = arguments.stream().map(MethodCallInfo.Argument::getResolvedType).toArray(TypeInfo[]::new); + resolvedMethod = getScriptMethodInfo(methodName, argTypes); + + // Second pass: Re-resolve arguments with expected parameter types if method was found + if (resolvedMethod != null && resolvedMethod.getParameters().size() == arguments.size()) { + arguments = parseMethodArguments(openParen + 1, closeParen, resolvedMethod); + } + + // Check if this is a method from a script type + // Instance methods from script types cannot be called without a receiver + if (resolvedMethod != null && isMethodFromScriptType(resolvedMethod) && !resolvedMethod.isStatic()) { + // Instance method called without receiver - mark as undefined + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.UNDEFINED_VAR)); + } else { + MethodCallInfo callInfo = new MethodCallInfo( + methodName, nameStart, nameEnd, openParen, closeParen, + arguments, null, resolvedMethod + ); + + // Only set expected type if this is the final expression (not followed by .field or .method) + if (!isFollowedByDot(closeParen)) { + TypeInfo expectedType = findExpectedTypeAtPosition(nameStart); + if (expectedType != null) { + callInfo.setExpectedType(expectedType); + } + } + + callInfo.validate(); + methodCalls.add(callInfo); + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_CALL, callInfo)); + } + } else { + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.UNDEFINED_VAR)); + } + } + } + } + } + + /** + * Check if a method call is a static access (Class.method() style). + * Returns true if the immediate receiver before the dot is a class name (uppercase). + */ + /** + * Check if a method call at the given position is static access. + * Walks backward from the method name to analyze the receiver. + */ + private boolean isStaticAccessCall(int methodNameStart) { + // Walk backward to find the dot + int pos = methodNameStart - 1; + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) + pos--; + + if (pos < 0 || text.charAt(pos) != '.') + return false; + + // Skip the dot and any whitespace + pos--; + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) + pos--; + + if (pos < 0) + return false; + + // Check what's before the dot - could be: + // 1. An identifier ending (field or class name) + // 2. A closing paren (method call result) + // 3. A closing bracket (array access) + + char c = text.charAt(pos); + if (c == ')' || c == ']') { + // Method call or array - would need complex expression resolution + // For now, conservatively treat as instance access + return false; + } + + if (!Character.isJavaIdentifierPart(c)) + return false; + + // Read the identifier backward + int identEnd = pos + 1; + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) + pos--; + int identStart = pos + 1; + + String ident = text.substring(identStart, identEnd); + + // Skip whitespace before the identifier + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) + pos--; + + // If preceded by a dot, this is part of a chain - treat as instance for now + if (pos >= 0 && text.charAt(pos) == '.') { + return false; + } + + // Direct identifier - check if it resolves to a type (static) or variable (instance) + if (ident.isEmpty()) return false; + + // Use unified static access checker + return TypeResolver.isStaticAccessExpression(ident, identStart, this); + } + + /** + * Find the MethodCallInfo that contains the given position as an argument. + * Returns null if the position is not inside any method call's argument list. + */ + private MethodCallInfo findMethodCallContainingPosition(int position) { + for (MethodCallInfo call : methodCalls) { + // Check if position is within the argument list (between open and close parens) + if (position >= call.getOpenParenOffset() && position <= call.getCloseParenOffset()) { + // Check if it's within any of the arguments + for (MethodCallInfo.Argument arg : call.getArguments()) { + if (position >= arg.getStartOffset() && position <= arg.getEndOffset()) { + return call; + } + } + } + } + return null; + } + + /** + * Find the matching closing parenthesis for an opening parenthesis. + * Handles nested parentheses, strings, and comments. + */ + private int findMatchingParen(int openPos) { + if (openPos < 0 || openPos >= text.length() || text.charAt(openPos) != '(') { + return -1; + } + + int depth = 1; + boolean inString = false; + boolean inChar = false; + char stringChar = 0; + + for (int i = openPos + 1; i < text.length(); i++) { + char c = text.charAt(i); + char prev = (i > 0) ? text.charAt(i - 1) : 0; + + // Handle string literals + if (!inChar && (c == '"' || c == '\'') && prev != '\\') { + if (!inString) { + inString = true; + stringChar = c; + } else if (c == stringChar) { + inString = false; + } + continue; + } + + if (inString) continue; + + // Skip excluded regions (comments) + if (isExcluded(i)) continue; + + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + return i; + } + } + } + + return -1; // No matching paren found + } + + /** + * Parse method arguments from the text between opening and closing parentheses. + * Handles nested expressions, strings, and complex argument types. + */ + /** + * Parse method call arguments. + * + * @param start Start position of arguments (after opening paren) + * @param end End position of arguments (at closing paren) + * @param methodInfo Optional MethodInfo to provide expected parameter types for validation + * @return List of parsed arguments with resolved types + */ + public List parseMethodArguments(int start, int end, MethodInfo methodInfo) { + List args = new ArrayList<>(); + + if (start >= end) + return args; // No arguments + + end = Math.min(end, text.length()); + + + int depth = 0; + int argStart = start; + boolean inString = false; + char stringChar = 0; + + for (int i = start; i <= end; i++) { + if (i == end || (depth == 0 && !inString && text.charAt(i) == ',')) { + // End of an argument + String argText = text.substring(argStart, i).trim(); + if (!argText.isEmpty()) { + // Find the actual start/end positions (excluding leading/trailing whitespace) + int actualStart = argStart; + while (actualStart < i && Character.isWhitespace(text.charAt(actualStart))) { + actualStart++; + } + int actualEnd = i; + while (actualEnd > actualStart && Character.isWhitespace(text.charAt(actualEnd - 1))) { + actualEnd--; + } + + // Try to resolve the argument type + // First check if this looks like a parameter declaration (Type varName) + // Get expected parameter type if available + TypeInfo expectedParamType = null; + if (methodInfo != null && args.size() < methodInfo.getParameters().size()) { + FieldInfo parameter = methodInfo.getParameters().get(args.size()); + expectedParamType = parameter.getTypeInfo(); + } + + TypeInfo argType = resolveArgumentType(argText, actualStart, expectedParamType); + + String samConflictError = CURRENT_SAM_CONFLICT_ERROR.get(); + if (samConflictError != null) { + CURRENT_SAM_CONFLICT_ERROR.remove(); + args.add(new MethodCallInfo.Argument( + argText, actualStart, actualEnd, argType, false, samConflictError + )); + } else { + args.add(new MethodCallInfo.Argument( + argText, actualStart, actualEnd, argType, true, null + )); + } + } + argStart = i + 1; + continue; + } + + char c = text.charAt(i); + char prev = (i > start) ? text.charAt(i - 1) : 0; + + // Handle string literals + if (!inString && (c == '"' || c == '\'') && prev != '\\') { + inString = true; + stringChar = c; + } else if (inString && c == stringChar && prev != '\\') { + inString = false; + } + + if (!inString) { + if (c == '(' || c == '[' || c == '{' || c == '<') { + depth++; + } else if (c == ')' || c == ']' || c == '}' || c == '>') { + depth--; + } + } + } + + return args; + } + + /** + * Resolve the type of a method call argument. + * Handles both parameter declarations (Type varName) and expressions (variable.field()). + * + * @param argText The argument text + * @param position The position in the document + * @param expectedType Optional expected parameter type for validation + * @return The resolved type of the argument + */ + private TypeInfo resolveArgumentType(String argText, int position, TypeInfo expectedType) { + argText = argText.trim(); + + // Check if this looks like a parameter declaration: "Type varName" + // Pattern: identifier followed by whitespace and another identifier + if (argText.matches("^[A-Za-z_][a-zA-Z0-9_<>\\[\\],\\s]*\\s+[a-zA-Z_][a-zA-Z0-9_]*$")) { + // Split into tokens + String[] parts = argText.split("\\s+"); + if (parts.length >= 2) { + // Last part is the variable name, everything before is the type + // Join all but last as the type name + StringBuilder typeBuilder = new StringBuilder(); + for (int i = 0; i < parts.length - 1; i++) { + if (i > 0) typeBuilder.append(" "); + typeBuilder.append(parts[i]); + } + String typeName = typeBuilder.toString(); + return resolveType(typeName); + } + } + + // Otherwise, treat it as an expression with expected type context + if (expectedType != null) { + ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = expectedType; + try { + return resolveExpressionType(argText, position); + } finally { + ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = null; + } + } + + // No expected type - resolve normally + return resolveExpressionType(argText, position); + } + + /** + * Check if a numeric literal has excessive precision for its type. + * Counts significant digits (excluding leading zeros, decimal point, and suffix). + * + * @param numLiteral The numeric literal string (e.g., "1.23456789f", "0.00123456789") + * @param maxDigits Maximum significant digits allowed (7 for float, 15 for double) + * @return true if the literal has more significant digits than allowed + */ + private boolean hasExcessivePrecision(String numLiteral, int maxDigits) { + String cleaned = numLiteral.trim(); + + // Remove leading sign + if (cleaned.startsWith("-") || cleaned.startsWith("+")) { + cleaned = cleaned.substring(1); + } + + // Remove suffix (f, F, d, D, l, L) + if (cleaned.endsWith("f") || cleaned.endsWith("F") || + cleaned.endsWith("d") || cleaned.endsWith("D") || + cleaned.endsWith("l") || cleaned.endsWith("L")) { + cleaned = cleaned.substring(0, cleaned.length() - 1); + } + + // Remove decimal point for counting + cleaned = cleaned.replace(".", ""); + + // Remove leading zeros (they're not significant in the mantissa) + while (cleaned.startsWith("0") && cleaned.length() > 1) { + cleaned = cleaned.substring(1); + } + + // If it's just "0", that's fine + if (cleaned.equals("0") || cleaned.isEmpty()) { + return false; + } + + // Count significant digits + int significantDigits = cleaned.length(); + + return significantDigits > maxDigits; + } + + /** + * Comprehensive expression type resolver that handles: + * - Literals (strings, numbers, booleans, null) + * - Variables (local, parameter, global) + * - Simple identifiers + * - Chained field accesses (a.b.c) + * - Chained method calls (a().b().c()) + * - Mixed chains (a.b().c.d()) + * - Static access (Class.field, Class.method()) + * - New expressions (new Type()) + * - All Java operators (arithmetic, logical, bitwise, etc.) + * + * This is THE method that should be used for all type resolution needs. + */ + public TypeInfo resolveExpressionType(String expr, int position) { + expr = expr.trim(); + + if (expr.isEmpty()) { + return null; + } + + // Check if expression contains operators - if so, use the full expression resolver + // 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); + } + + // Invalid expressions starting with brackets + if (expr.startsWith("[") || expr.startsWith("]")) { + return null; // Invalid syntax + } + + // String literals + if (expr.startsWith("\"") && expr.endsWith("\"")) { + return resolveType("String"); + } + + // Character literals + if (expr.startsWith("'") && expr.endsWith("'")) { + return TypeInfo.fromPrimitive("char"); + } + + // Boolean literals + if (expr.equals("true") || expr.equals("false")) { + return TypeInfo.fromPrimitive("boolean"); + } + + // Null literal + if (expr.equals("null")) { + return TypeInfo.unresolved("null", ""); // null is compatible with any reference type + } + + // Numeric literals with precision checking + // Float: can be 10f, 10.5f, 10.f, .5f + if (expr.matches("-?\\d*\\.?\\d+[fF]")) { + // Check if it has too many decimal places for float (>7 significant digits) + // If so, treat it as double (causing type mismatch) + if (hasExcessivePrecision(expr, 7)) { + return TypeInfo.fromPrimitive("double"); + } + return TypeInfo.fromPrimitive("float"); + } + // Double: can be 10d, 10.5d, 10.5, .5, 10., but NOT plain integers + if (expr.matches("-?\\d*\\.\\d+[dD]?") || expr.matches("-?\\d+\\.[dD]?") || expr.matches("-?\\d+[dD]")) { + // Check if it has too many decimal places for double (>15 significant digits) + // Return null to indicate the literal is invalid/unrepresentable + if (hasExcessivePrecision(expr, 15)) { + return null; // Exceeds double precision + } + return TypeInfo.fromPrimitive("double"); + } + // Long: 10L or 10l + if (expr.matches("-?\\d+[lL]")) { + return TypeInfo.fromPrimitive("long"); + } + // Int: plain integers without suffix + if (expr.matches("-?\\d+")) { + return TypeInfo.fromPrimitive("int"); + } + + // "this" keyword + if (expr.equals("this")) { + return findEnclosingScriptType(position); + } + + // "new Type()" expressions + if (expr.startsWith("new ")) { + Matcher newMatcher = NEW_TYPE_PATTERN.matcher(expr); + if (newMatcher.find()) { + String typeName = newMatcher.group(1); + + // First check if it's a variable holding a ClassTypeInfo (like var File = Java.type("java.io.File")) + FieldInfo varInfo = resolveVariable(typeName, position); + if (varInfo != null && varInfo.getTypeInfo() instanceof ClassTypeInfo) { + // It's a variable holding a class reference, return the wrapped class + ClassTypeInfo classRef = (ClassTypeInfo) varInfo.getTypeInfo(); + return classRef.getInstanceType(); + } + + // Otherwise treat it as a type name + return resolveType(typeName); + } + } + + // Now handle the complex case: chains of fields and method calls + // Parse the expression into segments + List segments = parseExpressionChain(expr); + + if (segments.isEmpty()) { + return null; + } + + // Resolve the first segment + ChainSegment first = segments.get(0); + TypeInfo currentType = null; + + if (first.name.equals("this")) { + currentType = findEnclosingScriptType(position); + // Handle this.field where we don't have a script type + if (currentType == null && segments.size() > 1 && !segments.get(1).isMethodCall) { + String fieldName = segments.get(1).name; + if (globalFields.containsKey(fieldName)) { + currentType = globalFields.get(fieldName).getTypeInfo(); + // Continue from segment 2 + for (int i = 2; i < segments.size(); i++) { + currentType = resolveChainSegment(currentType, segments.get(i)); + if (currentType == null) return null; + } + return currentType; + } + } + } else if (first.name.equals("super")) { + // Resolve super to parent class + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null && enclosingType.hasSuperClass()) { + currentType = enclosingType.getSuperClass(); + } else { + return null; // No parent class + } + } else { + // Check if first segment is a type name for static access + TypeInfo typeCheck = resolveType(first.name); + if (typeCheck != null && typeCheck.isResolved()) { + currentType = typeCheck; + } + } + + if (currentType == null && !first.isMethodCall) { + // Regular variable + FieldInfo varInfo = resolveVariable(first.name, position); + if (varInfo != null) { + currentType = varInfo.getTypeInfo(); + } else if (isScriptMethod(first.name)) { + // Script method reference used as an expression. + // In JS, this is commonly used as a SAM callback (e.g., schedule("id", actionFunction)). + TypeInfo expectedType = ExpressionTypeResolver.CURRENT_EXPECTED_TYPE; + TypeInfo samType = resolveScriptMethodAsSam(first.name, expectedType); + if (samType != null) { + currentType = samType; + } else if (isJavaScript()) { + // Provide a non-null placeholder type so overload selection can prefer functional-interface params. + currentType = TypeInfo.unresolved("", "__script_method_ref__"); + } + } + } else { + // First segment is a method call - check script methods + if (isScriptMethod(first.name)) { + MethodInfo scriptMethod = getScriptMethodInfo(first.name); + if (scriptMethod != null) { + currentType = scriptMethod.getReturnType(); + } + } + } + + // Resolve the rest of the chain + for (int i = 1; i < segments.size(); i++) { + currentType = resolveChainSegment(currentType, segments.get(i)); + if (currentType == null) { + return null; + } + } + + return currentType; + } + + /** + * Resolve a cast or parenthesized expression. + * Delegates to CastExpressionResolver helper class. + * Handles: + * - Simple casts: (Type) expr + * - Nested casts: ((Type) expr) + * - Cast chains: ((Type) expr).method() + * - Parenthesized expressions: (expr) + * + * @param expr The expression starting with '(' + * @param position The position in the source text + * @return The resolved type + */ + private TypeInfo resolveCastOrParenthesizedExpression(String expr, int position) { + return CastExpressionResolver.resolveCastOrParenthesizedExpression( + expr, + position, + this::resolveType, + this::resolveExpressionType, + this::parseExpressionChain, + this::resolveChainSegment + ); + } + + + /** + * Parse an expression string into chain segments. + * Handles dots, method calls, and nested expressions. + * Examples: + * - "a.b.c" -> [a, b, c] (all fields) + * - "a().b()" -> [a(), b()] (all methods) + * - "a.b().c" -> [a, b(), c] (mixed) + */ + private List parseExpressionChain(String expr) { + List segments = new ArrayList<>(); + int i = 0; + + while (i < expr.length()) { + // Skip whitespace + while (i < expr.length() && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + if (i >= expr.length()) break; + + // Read identifier + int start = i; + while (i < expr.length() && Character.isJavaIdentifierPart(expr.charAt(i))) { + i++; + } + + if (i == start) { + // Not an identifier, skip this character + i++; + continue; + } + + String name = expr.substring(start, i); + + // Skip whitespace + while (i < expr.length() && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + // Check if followed by parentheses (method call) + boolean isMethodCall = false; + String arguments = null; + if (i < expr.length() && expr.charAt(i) == '(') { + isMethodCall = true; + int argsStart = i + 1; + // Skip to the matching closing paren + int depth = 1; + i++; + while (i < expr.length() && depth > 0) { + char c = expr.charAt(i); + if (c == '(') depth++; + else if (c == ')') depth--; + i++; + } + // Extract argument text (between parentheses) + int argsEnd = i - 1; // Position of closing paren + if (argsEnd > argsStart) { + arguments = expr.substring(argsStart, argsEnd); + } else { + arguments = ""; // Empty arguments + } + } + + // Check if followed by array brackets (array access) + // Skip array accesses like [0] or [i] - treat them as part of the current segment + while (i < expr.length() && Character.isWhitespace(expr.charAt(i))) { + i++; + } + if (i < expr.length() && expr.charAt(i) == '[') { + // Skip to the matching closing bracket + int depth = 1; + i++; + while (i < expr.length() && depth > 0) { + char c = expr.charAt(i); + if (c == '[') depth++; + else if (c == ']') depth--; + i++; + } + } + + segments.add(new ChainSegment(name, start, i, isMethodCall, arguments)); + + // Skip whitespace + while (i < expr.length() && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + // Check for dot (continuing chain) + if (i < expr.length() && expr.charAt(i) == '.') { + i++; // Skip the dot + } + } + + return segments; + } + + /** + * Resolve the full receiver chain before a method call position. + * For example, for "mc.thePlayer.worldObj.weatherEffects.get()", + * this would resolve mc -> Minecraft -> thePlayer -> EntityPlayer -> worldObj -> World -> weatherEffects -> List + * and return the final TypeInfo (List). + * + * Also handles method calls in chains like "Minecraft.getMinecraft().thePlayer" by resolving + * the return type of getMinecraft() and continuing resolution. + * + * @param methodNameStart The start position of the method name + * @return The TypeInfo of the final receiver, or null if no receiver or couldn't resolve + */ + TypeInfo resolveReceiverChain(int methodNameStart) { + // First check if preceded by a dot + int scanPos = methodNameStart - 1; + while (scanPos >= 0 && Character.isWhitespace(text.charAt(scanPos))) + scanPos--; + + if (scanPos < 0 || text.charAt(scanPos) != '.') { + return null; // No receiver + } + + // Use the shared helper to locate the receiver expression before the dot, + // then delegate resolution to the comprehensive resolver. + int[] bounds = findReceiverBoundsBefore(scanPos); + if (bounds == null) return null; + int start = bounds[0]; + int end = bounds[1]; + String receiverExpr = text.substring(start, end).trim(); + if (receiverExpr.isEmpty()) return null; + return resolveExpressionType(receiverExpr, start); + } + + /** + * Parse argument types from a method call's argument list. + * @param argsText The text between parentheses (e.g., "20, 20" or "x, y.toString()") + * @param position The position in the source text + * @return Array of TypeInfo for each argument, or empty array if no arguments + */ + private TypeInfo[] parseArgumentTypes(String argsText, int position) { + if (argsText == null || argsText.trim().isEmpty()) { + return new TypeInfo[0]; + } + + // Split arguments by comma, respecting nested parentheses and strings + java.util.List args = new java.util.ArrayList<>(); + int depth = 0; + int start = 0; + boolean inString = false; + char stringChar = 0; + + for (int i = 0; i < argsText.length(); i++) { + char c = argsText.charAt(i); + + // Handle strings + if ((c == '"' || c == '\'') && (i == 0 || argsText.charAt(i-1) != '\\')) { + if (!inString) { + inString = true; + stringChar = c; + } else if (c == stringChar) { + inString = false; + } + continue; + } + + if (inString) continue; + + // Track parentheses depth + if (c == '(' || c == '[' || c == '{') { + depth++; + } else if (c == ')' || c == ']' || c == '}') { + depth--; + } else if (c == ',' && depth == 0) { + // Found argument separator at top level + args.add(argsText.substring(start, i).trim()); + start = i + 1; + } + } + + // Add the last argument + if (start < argsText.length()) { + args.add(argsText.substring(start).trim()); + } + + // Resolve each argument's type + TypeInfo[] argTypes = new TypeInfo[args.size()]; + for (int i = 0; i < args.size(); i++) { + argTypes[i] = resolveExpressionType(args.get(i), position); + } + + return argTypes; + } + + /** + * Helper class for chain segments (can be field access or method call). + */ + private static class ChainSegment { + final String name; + final int start; + final int end; + final boolean isMethodCall; + final String arguments; // The text between parentheses for method calls, or null for fields + + ChainSegment(String name, int start, int end, boolean isMethodCall, String arguments) { + this.name = name; + this.start = start; + this.end = end; + this.isMethodCall = isMethodCall; + this.arguments = arguments; + } + } + + /** + * Resolve a single segment of a chain given the current type context. + */ + private TypeInfo resolveChainSegment(TypeInfo currentType, ChainSegment segment) { + if (currentType == null || !currentType.isResolved()) { + return null; + } + + // Check if current type is a synthetic type + if (isJavaScript()) { + SyntheticType syntheticType = typeResolver.getSyntheticType(currentType.getSimpleName()); + if (syntheticType != null) { + return resolveSyntheticChainSegment(syntheticType, segment); + } + } + + if (segment.isMethodCall) { + // Method call - get return type with argument-based overload resolution + // Check for method existence using hierarchy search if it's a ScriptTypeInfo + boolean hasMethod = false; + if (currentType instanceof ScriptTypeInfo) { + hasMethod = ((ScriptTypeInfo) currentType).hasMethodInHierarchy(segment.name); + } else { + hasMethod = currentType.hasMethod(segment.name); + } + + if (hasMethod) { + // Parse argument types from the method call + TypeInfo[] argTypes = parseArgumentTypes(segment.arguments, segment.start); + + // Get the best matching overload based on argument types + // getBestMethodOverload is now overridden in ScriptTypeInfo to search hierarchy + MethodInfo methodInfo = currentType.getBestMethodOverload(segment.name, argTypes); + return (methodInfo != null) ? methodInfo.getReturnType() : null; + } + return null; + } else { + // Field access - use hierarchy search for ScriptTypeInfo + boolean hasField = false; + FieldInfo fieldInfo = null; + + if (currentType instanceof ScriptTypeInfo) { + hasField = ((ScriptTypeInfo) currentType).hasFieldInHierarchy(segment.name); + if (hasField) { + fieldInfo = ((ScriptTypeInfo) currentType).getFieldInfoInHierarchy(segment.name); + } + } else { + hasField = currentType.hasField(segment.name); + if (hasField) { + fieldInfo = currentType.getFieldInfo(segment.name); + } + } + + if (hasField) { + return (fieldInfo != null) ? fieldInfo.getTypeInfo() : null; + } + return null; + } + } + + /** + * Resolve a chain segment on a synthetic type (like Nashorn's Java object). + */ + /** + * Resolve a chain segment for a synthetic type (method call or field access). + * Handles dynamic return type resolution for methods like Java.type(). + */ + private TypeInfo resolveSyntheticChainSegment(SyntheticType syntheticType, ChainSegment segment) { + if (segment.isMethodCall) { + SyntheticMethod method = syntheticType.getMethod(segment.name); + if (method != null) { + // For methods with dynamic return type resolvers (like Java.type), + // extract string arguments and resolve + if (segment.arguments != null) { + String[] args = TypeResolver.parseStringArguments(segment.arguments); + TypeInfo resolved = method.resolveReturnType(args); + if (resolved != null) { + return resolved; + } + } + // Fall back to static return type + TypeInfo returnType = typeResolver.resolve(method.returnType); + return returnType != null ? returnType : TypeInfo.unresolved(method.returnType, method.returnType); + } + } else { + SyntheticField field = syntheticType.getField(segment.name); + if (field != null) { + TypeInfo fieldType = typeResolver.resolve(field.typeName); + return fieldType != null ? fieldType : TypeInfo.unresolved(field.typeName, field.typeName); + } + } + return null; + } + + + + // ==================== OPERATOR EXPRESSION RESOLUTION ==================== + + /** + * Check if an expression contains operators that need advanced resolution. + * This is a quick heuristic check - it may have false positives for operators + * inside strings, but those are handled by the full parser. + */ + public boolean containsOperators(String expr) { + if (expr == null) return false; + + boolean inString = false; + boolean inChar = false; + + for (int i = 0; i < expr.length(); i++) { + char c = expr.charAt(i); + char next = (i + 1 < expr.length()) ? expr.charAt(i + 1) : 0; + + // Track string literals + if (c == '"' && !inChar) { + if (!inString) { + inString = true; + } else if (i > 0 && expr.charAt(i - 1) != '\\') { + inString = false; + } + continue; + } + + // Track char literals + if (c == '\'' && !inString) { + if (!inChar) { + inChar = true; + } else if (i > 0 && expr.charAt(i - 1) != '\\') { + inChar = false; + } + continue; + } + + // Skip content inside strings/chars + if (inString || inChar) continue; + + // Check for operators (excluding . which is used for member access) + switch (c) { + case '+': + // + is arithmetic unless it's unary at start or after operator + if (next == '+') return true; // ++ + if (next == '=') return true; // += + // Check if this is binary + by looking at previous non-whitespace + int prevIdx = i - 1; + while (prevIdx >= 0 && Character.isWhitespace(expr.charAt(prevIdx))) prevIdx--; + if (prevIdx >= 0) { + char prev = expr.charAt(prevIdx); + // If previous char is identifier char or ), this is binary + if (Character.isJavaIdentifierPart(prev) || prev == ')' || prev == ']' || Character.isDigit(prev)) { + return true; + } + } + break; + + case '-': + // - is arithmetic unless it's unary minus before a number + if (next == '-') return true; // -- + if (next == '=') return true; // -= + // Check if this is binary - by looking at previous non-whitespace + prevIdx = i - 1; + while (prevIdx >= 0 && Character.isWhitespace(expr.charAt(prevIdx))) prevIdx--; + if (prevIdx >= 0) { + char prev = expr.charAt(prevIdx); + // If previous char is identifier char or ), this is binary + if (Character.isJavaIdentifierPart(prev) || prev == ')' || prev == ']' || Character.isDigit(prev)) { + return true; + } + } + break; + + case '*': case '/': case '%': + return true; + + case '&': + if (next == '&' || next == '=') return true; + // Single & is bitwise AND + return true; + + case '|': + if (next == '|' || next == '=') return true; + // Single | is bitwise OR + return true; + + case '^': case '~': + return true; + + case '<': + // Could be < > <= >= << or generics + if (next == '<' || next == '=') return true; + // Check if this looks like relational (not generic type params) + // Generics typically follow a type name directly with no space + int nextNonSpace = i + 1; + while (nextNonSpace < expr.length() && Character.isWhitespace(expr.charAt(nextNonSpace))) nextNonSpace++; + if (nextNonSpace < expr.length() && !Character.isUpperCase(expr.charAt(nextNonSpace))) { + return true; + } + break; + + case '>': + if (next == '>' || next == '=') return true; + // Similar check for generics + break; + + case '!': + if (next == '=') return true; // != + // Standalone ! is logical NOT + return true; + + case '=': + if (next == '=') return true; // == + // Single = is assignment + return true; + + case '?': + // Ternary operator - but be careful of generics with ? + // If followed by :, it's definitely ternary + for (int j = i + 1; j < expr.length(); j++) { + if (expr.charAt(j) == ':') return true; + } + break; + } + } + + // Check for instanceof keyword + if (expr.contains(" instanceof ")) { + return true; + } + + return false; + } + + /** + * Check if an expression looks like a JS function or arrow lambda. + * This is a fast heuristic to detect: + * - JS function expressions: function(...) {} + * - Arrow lambdas: (...) => ... + * These need to be routed through the parser for proper SAM typing. + */ + private boolean looksLikeFunctionOrLambda(String expr) { + if (expr == null || expr.isEmpty()) return false; + String trimmed = expr.trim(); + if (trimmed.startsWith("function")) return true; + if (trimmed.contains("=>")) return true; + if (trimmed.contains("->")) return true; + return false; + } + + /** + * Resolve an expression that contains operators or casts using the full expression parser. + * This handles all Java operators with proper precedence and type promotion rules. + */ + private TypeInfo resolveExpressionWithParserAPI(String expr, int position) { + // Create a context that bridges to ScriptDocument's existing resolution methods + ExpressionNode.TypeResolverContext context = createExpressionResolverContext(position); + + // Use the expression resolver to parse and resolve the type + // Pass the position as basePosition so lambda position calculations work correctly + ExpressionTypeResolver resolver = new ExpressionTypeResolver(context, this, position); + TypeInfo result = resolver.resolve(expr); + + // Special case: null literal type is "unresolved" but valid + if (result != null && "".equals(result.getFullName())) { + return result; + } + + // If the expression resolver couldn't resolve it, fall back to simple resolution + if (result == null || !result.isResolved()) { + // Try the simple path in case the operator detection was a false positive + return resolveSimpleExpression(expr, position); + } + + return result; + } + + /** + * Create a type resolver context that connects the expression resolver + * to ScriptDocument's existing type resolution infrastructure. + */ + private ExpressionNode.TypeResolverContext createExpressionResolverContext(int position) { + return new ExpressionNode.TypeResolverContext() { + @Override + public TypeInfo resolveIdentifier(String name) { + if ("this".equals(name)) { + return findEnclosingScriptType(position); + } + if ("super".equals(name)) { + // Resolve super to parent class type + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null && enclosingType.hasSuperClass()) { + return enclosingType.getSuperClass(); + } + return null; + } + if ("true".equals(name) || "false".equals(name)) { + return TypeInfo.fromPrimitive("boolean"); + } + if ("null".equals(name)) { + return TypeInfo.unresolved("null", ""); + } + + // Variable shadowing: variables take precedence over script methods + FieldInfo varInfo = resolveVariable(name, position); + if (varInfo != null) { + return varInfo.getTypeInfo(); + } + + if (name.length() > 0) { + TypeInfo typeCheck = resolveType(name); + if (typeCheck != null && typeCheck.isResolved()) { + return typeCheck; + } + } + + // Named script function as SAM callback: schedule("id", actionFunction) + TypeInfo expectedType = ExpressionTypeResolver.CURRENT_EXPECTED_TYPE; + TypeInfo samType = resolveScriptMethodAsSam(name, expectedType); + if (samType != null) { + return samType; + } + + return null; + } + + @Override + public TypeInfo resolveMemberAccess(TypeInfo targetType, String memberName) { + if (targetType == null || !targetType.isResolved()) { + return null; + } + + if (targetType.hasField(memberName)) { + FieldInfo fieldInfo = targetType.getFieldInfo(memberName); + return (fieldInfo != null) ? fieldInfo.getTypeInfo() : null; + } + + return null; + } + + @Override + public TypeInfo resolveMethodCall(TypeInfo targetType, String methodName, TypeInfo[] argTypes) { + if (targetType == null || !targetType.isResolved()) { + // Try as a script-defined method + if (isScriptMethod(methodName)) { + MethodInfo scriptMethod = getScriptMethodInfo(methodName); + if (scriptMethod != null) { + return scriptMethod.getReturnType(); + } + } + return null; + } + + if (targetType.hasMethod(methodName)) { + MethodInfo methodInfo = targetType.getBestMethodOverload(methodName, argTypes); + return (methodInfo != null) ? methodInfo.getReturnType() : null; + } + + return null; + } + + @Override + public TypeInfo resolveArrayAccess(TypeInfo arrayType) { + if (arrayType == null || !arrayType.isResolved()) { + return null; + } + + String typeName = arrayType.getFullName(); + if (typeName.endsWith("[]")) { + String elementTypeName = typeName.substring(0, typeName.length() - 2); + // Try to resolve the element type properly + return resolveType(elementTypeName); + } + + // For List or similar, try to extract the element type + // This is a simplified version - could be enhanced for full generic support + return null; + } + + @Override + public TypeInfo resolveTypeName(String typeName) { + return resolveType(typeName); + } + }; + } + + /** + * Resolve a simple expression without operators. + * This is the fallback path when operator detection was a false positive. + */ + private TypeInfo resolveSimpleExpression(String expr, int position) { + // String literals + if (expr.startsWith("\"") && expr.endsWith("\"")) { + return resolveType("String"); + } + + // Character literals + if (expr.startsWith("'") && expr.endsWith("'")) { + return TypeInfo.fromPrimitive("char"); + } + + // Boolean literals + if (expr.equals("true") || expr.equals("false")) { + return TypeInfo.fromPrimitive("boolean"); + } + + // Null literal + if (expr.equals("null")) { + return null; + } + + // Numeric literals + if (expr.matches("-?\\d*\\.?\\d+[fF]")) { + if (hasExcessivePrecision(expr, 7)) { + return TypeInfo.fromPrimitive("double"); + } + return TypeInfo.fromPrimitive("float"); + } + if (expr.matches("-?\\d*\\.\\d+[dD]?") || expr.matches("-?\\d+\\.[dD]?") || expr.matches("-?\\d+[dD]")) { + if (hasExcessivePrecision(expr, 15)) { + return null; + } + return TypeInfo.fromPrimitive("double"); + } + if (expr.matches("-?\\d+[lL]")) { + return TypeInfo.fromPrimitive("long"); + } + if (expr.matches("-?\\d+")) { + return TypeInfo.fromPrimitive("int"); + } + + // "this" keyword + if (expr.equals("this")) { + return findEnclosingScriptType(position); + } + + // "new Type()" expressions + if (expr.startsWith("new ")) { + Matcher newMatcher = NEW_TYPE_PATTERN.matcher(expr); + if (newMatcher.find()) { + String typeName = newMatcher.group(1); + + // First check if it's a variable holding a ClassTypeInfo (like var File = Java.type("java.io.File")) + FieldInfo varInfo = resolveVariable(typeName, position); + if (varInfo != null && varInfo.getTypeInfo() instanceof ClassTypeInfo) { + // It's a variable holding a class reference, return the wrapped class + ClassTypeInfo classRef = (ClassTypeInfo) varInfo.getTypeInfo(); + return classRef.getInstanceType(); + } + + // Otherwise treat it as a type name + return resolveType(typeName); + } + } + + // Try as variable or field chain + List segments = parseExpressionChain(expr); + if (segments.isEmpty()) { + return null; + } + + ChainSegment first = segments.get(0); + TypeInfo currentType = null; + + if (first.name.equals("this")) { + currentType = findEnclosingScriptType(position); + } else { + // Check if first segment is a type name + TypeInfo typeCheck = resolveType(first.name); + if (typeCheck != null && typeCheck.isResolved()) { + currentType = typeCheck; + } + } + + if (currentType == null && !first.isMethodCall) { + FieldInfo varInfo = resolveVariable(first.name, position); + if (varInfo != null) { + currentType = varInfo.getTypeInfo(); + } + } else { + if (isScriptMethod(first.name)) { + MethodInfo scriptMethod = getScriptMethodInfo(first.name); + if (scriptMethod != null) { + currentType = scriptMethod.getReturnType(); + } + } + } + + for (int i = 1; i < segments.size(); i++) { + currentType = resolveChainSegment(currentType, segments.get(i)); + if (currentType == null) { + return null; + } + } + + return currentType; + } + + /** + * Find the matching opening parenthesis when given a closing paren position. + */ + private int findMatchingParenBackward(int closeParenPos) { + if (closeParenPos < 0 || closeParenPos >= text.length() || text.charAt(closeParenPos) != ')') { + return -1; + } + + int depth = 1; + boolean inString = false; + char stringChar = 0; + + for (int i = closeParenPos - 1; i >= 0; i--) { + char c = text.charAt(i); + char next = (i < text.length() - 1) ? text.charAt(i + 1) : 0; + + // Handle string literals (going backward, check for escapes) + if (!inString && (c == '"' || c == '\'')) { + // Check if this quote is escaped (look back for backslash) + int backslashCount = 0; + for (int j = i - 1; j >= 0 && text.charAt(j) == '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 == 0) { + inString = true; + stringChar = c; + } + } else if (inString && c == stringChar) { + int backslashCount = 0; + for (int j = i - 1; j >= 0 && text.charAt(j) == '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 == 0) { + inString = false; + } + } + + if (inString) continue; + + // Skip excluded regions (comments) + if (isExcluded(i)) continue; + + if (c == ')') { + depth++; + } else if (c == '(') { + depth--; + if (depth == 0) { + return i; + } + } + } + + return -1; // No matching paren found + } + + /** + * Find the matching opening bracket of a given closing bracket position. + * Supports any pair of open/close chars (e.g., '[' and ']'). + */ + private int findMatchingBracketBackward(int closePos, char openChar, char closeChar) { + if (closePos < 0 || closePos >= text.length() || text.charAt(closePos) != closeChar) { + return -1; + } + + int depth = 1; + boolean inString = false; + char stringChar = 0; + + for (int i = closePos - 1; i >= 0; i--) { + char c = text.charAt(i); + + // Handle string literals (backward) + if (!inString && (c == '"' || c == '\'')) { + int backslashCount = 0; + for (int j = i - 1; j >= 0 && text.charAt(j) == '\\'; j--) backslashCount++; + if (backslashCount % 2 == 0) { + inString = true; + stringChar = c; + } + } else if (inString && c == stringChar) { + int backslashCount = 0; + for (int j = i - 1; j >= 0 && text.charAt(j) == '\\'; j--) backslashCount++; + if (backslashCount % 2 == 0) { + inString = false; + } + } + + if (inString) continue; + + if (isExcluded(i)) continue; + + if (c == closeChar) depth++; + else if (c == openChar) { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + /** + * Given the position of a dot ('.') in `text`, find the bounds [start, end) + * of the receiver expression immediately to the left of the dot. The end + * returned will be the dot index (exclusive). Returns null if not found or malformed. + */ + int[] findReceiverBoundsBefore(int dotIndex) { + if (dotIndex < 0 || dotIndex >= text.length() || text.charAt(dotIndex) != '.') { + return null; + } + + int pos = dotIndex - 1; + // Skip whitespace + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) pos--; + if (pos < 0) return null; + + while (pos >= 0) { + // Check if we're in an excluded range (comment, string, etc.) + // Skip excluded regions to avoid picking up types from comment text + if (isExcluded(pos)) { + // Find the excluded range and skip to before it + for (int[] range : excludedRanges) { + if (pos >= range[0] && pos < range[1]) { + pos = range[0] - 1; // Jump to before the excluded range + break; + } + } + continue; // Continue scanning from before the excluded range + } + + char c = text.charAt(pos); + if (Character.isWhitespace(c)) { pos--; continue; } + + if (c == ')') { + int open = findMatchingParenBackward(pos); + if (open < 0) return null; + pos = open - 1; + continue; + } + + if (c == ']') { + int open = findMatchingBracketBackward(pos, '[', ']'); + if (open < 0) return null; + pos = open - 1; + continue; + } + + if (Character.isJavaIdentifierPart(c)) { + while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) pos--; + // Skip whitespace to check for chained identifier (preceded by a dot) + int checkPos = pos; + while (checkPos >= 0 && Character.isWhitespace(text.charAt(checkPos))) checkPos--; + // If it's part of a chained identifier (preceded by a dot), continue + if (checkPos >= 0 && text.charAt(checkPos) == '.') { pos = checkPos - 1; continue; } + break; + } + + // Unexpected char - stop and treat as start after this + break; + } + + int start = pos + 1; + int end = dotIndex; // exclusive + if (start >= end) return null; + return new int[]{start, end}; + } + + /** + * Find the script-defined type that contains the given position. + * Used for resolving 'this' references. + */ + ScriptTypeInfo findEnclosingScriptType(int position) { + for (ScriptTypeInfo type : scriptTypes.values()) { + if (type.containsPosition(position)) { + return type; + } + } + return null; + } + + /** + * Find the constructor that contains the given position. + * Used for validating super() calls. + */ + private MethodInfo findEnclosingConstructor(int position) { + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType == null) + return null; + + for (MethodInfo constructor : enclosingType.getConstructors()) { + int bodyStart = constructor.getBodyStart(); + int bodyEnd = constructor.getBodyEnd(); + if (bodyStart >= 0 && bodyEnd > bodyStart && position >= bodyStart && position < bodyEnd) { + return constructor; + } + } + return null; + } + + /** + * Handle super() constructor calls with validation and argument matching. + */ + private void handleSuperConstructorCall(List marks, int nameStart, int nameEnd, int openParen, + int closeParen) { + // Validate that we're in a constructor + TokenErrorMessage errorMsg = null; + + //Parent class + ScriptTypeInfo enclosingType = findEnclosingScriptType(nameStart); + MethodInfo enclosingConstructor = findEnclosingConstructor(nameStart); + TypeInfo superClass = enclosingType != null ? enclosingType.getSuperClass() : null; + + // Parse arguments and find matching parent constructor + List arguments = parseMethodArguments(openParen + 1, closeParen, null); + TypeInfo[] argTypes = arguments.stream().map(MethodCallInfo.Argument::getResolvedType).toArray(TypeInfo[]::new); + + // Find matching constructor in parent class + MethodInfo parentConstructor = superClass != null ? superClass.findConstructor(argTypes) : null; + + if (enclosingType == null || !enclosingType.hasSuperClass()) { + // No parent class - error + errorMsg = TokenErrorMessage + .from(enclosingType == null ? "'super()' can only be used within a class" : "Class '" + enclosingType.getSimpleName() + "' does not have a parent class") + .clearOtherErrors(); + } else if (enclosingConstructor == null) { + // Not in a constructor - error + errorMsg = TokenErrorMessage.from("Call to 'super()' only allowed in constructor body").clearOtherErrors(); + } else if (superClass == null || !superClass.isResolved()) { + // Parent class not resolved - error + errorMsg = TokenErrorMessage.from( + "Cannot resolve parent class '" + enclosingType.getSuperClassName() + "'"); + } else if (parentConstructor == null) { + // No matching constructor found + String argTypeStr = java.util.Arrays.stream(argTypes) + .map(t -> t != null ? t.getSimpleName() : "unknown") + .collect(java.util.stream.Collectors.joining(", ")); + errorMsg = TokenErrorMessage + .from("No constructor found in '" + superClass.getSimpleName() + "' matching super(" + argTypeStr + ")") + .clearOtherErrors(); + } + + // Successfully resolved - mark as a valid method call (constructor call) + MethodCallInfo callInfo = new MethodCallInfo("super", nameStart, nameEnd, openParen, closeParen, arguments, + superClass, parentConstructor, false).setConstructor(true); + callInfo.validate(); + methodCalls.add(callInfo); + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.IMPORT_KEYWORD, + errorMsg != null ? errorMsg : callInfo)); + } + + /** + * Check if a method name is defined in this script. + */ + private boolean isScriptMethod(String methodName) { + for (MethodInfo method : methods) { + if (method.getName().equals(methodName)) { + return true; + } + } + return false; + } + + private TypeInfo resolveScriptMethodAsSam(String methodName, TypeInfo expectedType) { + if (methodName == null || expectedType == null || !expectedType.isFunctionalInterface() || !isScriptMethod(methodName)) { + return null; + } + + // Java mode: bare method names are invalid as SAM callbacks + if (!isJavaScript()) { + CURRENT_SAM_CONFLICT_ERROR.set( + "Bare method name '" + methodName + "' cannot be used as callback in Java. Use this::" + methodName + ); + return expectedType; + } + + MethodInfo sam = expectedType.getSingleAbstractMethod(); + if (sam == null) { + return null; + } + + int samArity = sam.getParameters().size(); + MethodInfo bestMatch = getScriptMethodBySamArity(methodName, samArity); + if (bestMatch == null) { + return null; + } + + injectSamParameterTypes(bestMatch, sam); + return expectedType; + } + + /** + * Get the MethodInfo for a script-defined method by name. + */ + private MethodInfo getScriptMethodInfo(String methodName) { + for (MethodInfo method : methods) { + if (method.getName().equals(methodName)) { + return method; + } + } + return null; + } + + /** + * Get the best matching script method overload based on argument types. + */ + private MethodInfo getScriptMethodInfo(String methodName, TypeInfo[] argTypes) { + List candidates = new ArrayList<>(); + + // Collect all methods with matching name + for (MethodInfo method : methods) { + if (method.getName().equals(methodName)) { + candidates.add(method); + } + } + + if (candidates.isEmpty()) { + return null; + } + + if (candidates.size() == 1) { + return candidates.get(0); + } + + // Use same 3-phase matching as TypeInfo.getBestMethodOverload + + // Phase 1: Look for exact match + for (MethodInfo method : candidates) { + List params = method.getParameters(); + if (params.size() != argTypes.length) { + continue; + } + + boolean exactMatch = true; + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramType = params.get(i).getDeclaredType(); + TypeInfo argType = argTypes[i]; + + if (argType == null || paramType == null || !argType.equals(paramType)) { + exactMatch = false; + break; + } + } + + if (exactMatch) { + return method; + } + } + + // Phase 2: Look for compatible match (widening/autoboxing) + for (MethodInfo method : candidates) { + List params = method.getParameters(); + if (params.size() != argTypes.length) { + continue; + } + + boolean compatible = true; + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramType = params.get(i).getDeclaredType(); + TypeInfo argType = argTypes[i]; + + if (argType != null && paramType != null && !TypeChecker.isTypeCompatible(paramType, argType)) { + compatible = false; + break; + } + } + + if (compatible) { + return method; + } + } + + // Phase 3: Fallback to first overload + return candidates.get(0); + } + + /** + * Get the best matching script method for use as a SAM callback by arity. + * Returns null if no match found, or if multiple overloads match by arity (ambiguous). + * + * @param methodName Name of the script method + * @param samArity Number of parameters in the SAM interface method + * @return The matching MethodInfo, or null if no unambiguous match + */ + private MethodInfo getScriptMethodBySamArity(String methodName, int samArity) { + List matchingByArity = new ArrayList<>(); + + for (MethodInfo method : methods) { + if (method.getName().equals(methodName)) { + if (method.getParameters().size() == samArity) { + matchingByArity.add(method); + } + } + } + + if (matchingByArity.size() == 1) { + return matchingByArity.get(0); + } + + if (matchingByArity.isEmpty()) { + return getScriptMethodInfo(methodName); + } + + CURRENT_SAM_CONFLICT_ERROR.set( + "Ambiguous overload for '" + methodName + "' with " + samArity + " parameter(s): " + + matchingByArity.size() + " overloads match" + ); + return null; + } + + private void injectSamParameterTypes(MethodInfo scriptMethod, MethodInfo sam) { + String methodName = scriptMethod.getName(); + + MethodInfo previousSam = scriptMethodSamContexts.get(methodName); + if (previousSam != null) { + if (!areSamSignaturesCompatible(previousSam, sam)) { + String existingSig = formatSamSignature(previousSam); + String newSig = formatSamSignature(sam); + CURRENT_SAM_CONFLICT_ERROR.set( + "Function '" + methodName + "' used in incompatible SAM contexts: " + + existingSig + " vs " + newSig + ); + return; + } + } else { + scriptMethodSamContexts.put(methodName, sam); + } + + List scriptParams = scriptMethod.getParameters(); + List samParams = sam.getParameters(); + + if (scriptParams.size() != samParams.size()) { + return; + } + + for (int i = 0; i < scriptParams.size(); i++) { + FieldInfo scriptParam = scriptParams.get(i); + TypeInfo samParamType = samParams.get(i).getTypeInfo(); + + if (samParamType == null) { + continue; + } + + TypeInfo declaredType = scriptParam.getDeclaredType(); + + boolean hasExplicitType = declaredType != null + && declaredType.isResolved() + && !"any".equals(declaredType.getFullName()); + + if (hasExplicitType) { + if (!TypeChecker.isTypeCompatible(declaredType, samParamType)) { + scriptMethod.addSamTypeError(i, samParamType, declaredType); + } + } else { + scriptParam.setInferredType(samParamType); + } + } + } + + private boolean areSamSignaturesCompatible(MethodInfo sam1, MethodInfo sam2) { + List params1 = sam1.getParameters(); + List params2 = sam2.getParameters(); + + if (params1.size() != params2.size()) { + return false; + } + + for (int i = 0; i < params1.size(); i++) { + TypeInfo type1 = params1.get(i).getTypeInfo(); + TypeInfo type2 = params2.get(i).getTypeInfo(); + + if (type1 == null || type2 == null) { + continue; + } + + if (!TypeChecker.isTypeCompatible(type1, type2) && !TypeChecker.isTypeCompatible(type2, type1)) { + return false; + } + } + + TypeInfo return1 = sam1.getReturnType(); + TypeInfo return2 = sam2.getReturnType(); + + if (return1 != null && return2 != null) { + if (!TypeChecker.isTypeCompatible(return1, return2) && !TypeChecker.isTypeCompatible(return2, return1)) { + return false; + } + } + + return true; + } + + private String formatSamSignature(MethodInfo sam) { + StringBuilder sb = new StringBuilder(); + sb.append("("); + List params = sam.getParameters(); + for (int i = 0; i < params.size(); i++) { + if (i > 0) sb.append(", "); + TypeInfo type = params.get(i).getTypeInfo(); + sb.append(type != null ? type.getSimpleName() : "?"); + } + sb.append(") -> "); + TypeInfo returnType = sam.getReturnType(); + sb.append(returnType != null ? returnType.getSimpleName() : "void"); + return sb.toString(); + } + + /** + * Check if a method belongs to a script-defined type (class/interface/enum). + * Returns true if the method is defined inside a script type. + */ + private boolean isMethodFromScriptType(MethodInfo method) { + if (method == null || !method.isDeclaration()) { + return false; + } + + int declPos = method.getDeclarationOffset(); + if (declPos < 0) { + return false; + } + + // Check if the method's declaration position is inside any script type's body + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + if (scriptType.containsPosition(declPos)) { + return true; + } + } + + return false; + } + + private void markVariables(List marks) { + Set knownKeywords = new HashSet<>(Arrays.asList( + "boolean", "int", "float", "double", "long", "char", "byte", "short", "void", + "null", "true", "false", "if", "else", "switch", "case", "for", "while", "do", + "try", "catch", "finally", "return", "throw", "var", "let", "const", "function", + "continue", "break", "this", "new", "typeof", "instanceof", "class", "interface", + "extends", "implements", "import", "package", "public", "private", "protected", + "static", "final", "abstract", "synchronized", "native", "default", "enum", + "throws", "super", "assert", "volatile", "transient" + )); + + // First pass: mark method parameters in their declaration positions + List allMethods = getAllMethods(); + allMethods.addAll(getAllConstructors()); + + for (MethodInfo method : allMethods) { + for (FieldInfo param : method.getParameters()) { + int pos = param.getDeclarationOffset(); + String name = param.getName(); + marks.add(new ScriptLine.Mark(pos, pos + name.length(), TokenType.PARAMETER, param)); + } + } + + Pattern identifier = Pattern.compile("\\b([a-zA-Z_][a-zA-Z0-9_]*)\\b"); + Matcher m = identifier.matcher(text); + + while (m.find()) { + String name = m.group(1); + int position = m.start(1); + + if (isExcluded(position)) + continue; + if (knownKeywords.contains(name)) + continue; + + // Skip if preceded by dot (field access - handled separately) + if (isPrecededByDot(position)) + continue; + + // Skip import/package statements + if (isInImportOrPackage(position)) + continue; + + // Skip method calls (followed by paren) + if (isFollowedByParen(m.end(1))) + continue; + + // Find containing method + MethodInfo containingMethod = findMethodAtPosition(position); + // Check if this variable is an argument to a method call + MethodCallInfo callInfo = findMethodCallContainingPosition(position); + + // For uppercase identifiers, only process if it's a known field + // Otherwise, let type handling (markImportedClassUsages) handle it + boolean isUppercase = Character.isUpperCase(name.charAt(0)); + + // Scope resolution order: + // 1. Inner callable scope parameters (lambda/function expressions) + // 2. Inner callable scope locals + // 3. Method parameters (if inside method) + // 4. Method local variables (if inside method) + // 5. Enclosing type fields (if inside method) + // 6. Global fields + // 7. Script type fields + + // Check inner callable scope parameters first (lambda/function expressions) + Object innermostScope = findInnermostScopeAt(m.start(1)); + if (innermostScope instanceof InnerCallableScope) { + InnerCallableScope innerScope = (InnerCallableScope) innermostScope; + FieldInfo innerParam = innerScope.getParameter(name); + if (innerParam != null) { + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.PARAMETER, innerParam)); + continue; + } + // Also check locals + FieldInfo innerLocal = innerScope.getLocals().get(name); + if (innerLocal != null) { + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.LOCAL_FIELD, innerLocal)); + continue; + } + } + + // Check parameters (method scope) - fallback if not in inner scope + if (containingMethod != null && containingMethod.hasParameter(name)) { + FieldInfo paramInfo = containingMethod.getParameter(name); + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.PARAMETER, paramInfo)); + continue; + } + + // Check local variables (method scope) + if (containingMethod != null) { + Map locals = methodLocals.get(containingMethod.getDeclarationOffset()); + if (locals != null && locals.containsKey(name)) { + FieldInfo localInfo = locals.get(name); + if (localInfo.isVisibleAt(position)) { + 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) + if (containingMethod != null) { + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null && enclosingType.hasField(name)) { + FieldInfo fieldInfo = enclosingType.getFieldInfo(name); + if (fieldInfo.isVisibleAt(position)) { + Object metadata = callInfo != null ? new FieldInfo.ArgInfo(fieldInfo, callInfo) : fieldInfo; + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.GLOBAL_FIELD, metadata)); + continue; + } + } + } + + // Check other script type fields (only if position is within that type's boundaries) + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + if (scriptType.hasField(name)) { + // Only check if position is within this script type's class body + if (position >= scriptType.getBodyStart() && position <= scriptType.getBodyEnd()) { + FieldInfo fieldInfo = scriptType.getFieldInfo(name); + if(fieldInfo.isEnumConstant()) + continue; + + if (fieldInfo.isVisibleAt(position)) { + Object metadata = callInfo != null ? new FieldInfo.ArgInfo(fieldInfo, callInfo) : fieldInfo; + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.GLOBAL_FIELD, metadata)); + continue; + } + } + } + } + + // Check global fields + if (globalFields.containsKey(name)) { + FieldInfo fieldInfo = globalFields.get(name); + if (fieldInfo.isVisibleAt(position)) { + Object metadata = callInfo != null ? new FieldInfo.ArgInfo(fieldInfo, callInfo) : fieldInfo; + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.GLOBAL_FIELD, metadata)); + continue; + } + } + + if (isJavaScript()) { + // check if a variable in global objects + JSTypeRegistry registry = JSTypeRegistry.getInstance(); + String globalObjectType = registry.getGlobalObjectType(name); + if (globalObjectType != null) { + // Resolve the type and create a FieldInfo for it + FieldInfo fieldInfo = resolveVariable(name, position); + if (fieldInfo != null && fieldInfo.isResolved()) { + Object metadata = callInfo != null ? new FieldInfo.ArgInfo(fieldInfo, callInfo) : fieldInfo; + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.GLOBAL_FIELD, metadata)); + continue; + } + } + + // Mark DataScript global variable definitions + if (!editorGlobals.isEmpty() && editorGlobals.containsKey(name)) { + FieldInfo fieldInfo = resolveVariable(name, position); + if (fieldInfo != null) { + Object metadata = callInfo != null ? new FieldInfo.ArgInfo(fieldInfo, callInfo) : fieldInfo; + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.GLOBAL_FIELD, metadata)); + continue; + } + } + } + + // Skip uppercase if not a known field - type handling will deal with it + if (isUppercase) + continue; + + // Check if it's a script method used as a value (e.g., in schedule("action", methodName)) + if (isScriptMethod(name)) { + MethodInfo scriptMethod = getScriptMethodInfo(name); + if (scriptMethod != null) { + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.METHOD_CALL, scriptMethod)); + continue; + } + } + + // Unknown variable - mark as undefined + if (containingMethod != null) { + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.UNDEFINED_VAR, callInfo)); + } + } + } + + /** + * Mark chained field accesses like: mc.player.world, array.length, this.field, etc. + * This handles dot-separated access chains and colors each segment appropriately. + * Does NOT mark method calls (identifiers followed by parentheses) - those are handled by {@link #markMethodCalls}. + */ + private void markChainedFieldAccesses(List marks) { + FieldChainMarker marker = new FieldChainMarker(this, text); + marker.markChainedFieldAccesses(marks); + } + + + /** + * Parse assignment statements (reassignments, not declarations) and validate type compatibility. + * This detects patterns like: varName = expr; or obj.field = expr; + * Assignments are stored in the corresponding FieldInfo, not as marks. + */ + private void parseAssignments() { + // First clear any existing assignments in all FieldInfo objects + for (FieldInfo field : globalFields.values()) { + field.clearAssignments(); + } + for (Map locals : methodLocals.values()) { + for (FieldInfo field : locals.values()) { + field.clearAssignments(); + } + } + // Also clear assignments in script type fields + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + for (FieldInfo field : scriptType.getFields().values()) { + field.clearAssignments(); + } + } + externalFieldAssignments.clear(); + + // Pattern to find assignments: identifier = expression; + // But NOT declarations (Type varName = expr) or compound assignments (+=, -=, etc.) + // We need to find '=' that is: + // 1. Not preceded by another operator (!, <, >, =) + // 2. Not followed by '=' + // 3. The LHS must be a valid l-value (variable or field access) + + int pos = 0; + while (pos < text.length()) { + // Find the next '=' character + int equalsPos = text.indexOf('=', pos); + if (equalsPos < 0) break; + + // Skip if in excluded region (string/comment) + if (isExcluded(equalsPos)) { + pos = equalsPos + 1; + continue; + } + + // Check it's not part of ==, !=, <=, >=, +=, -=, *=, /=, %=, &=, |=, ^= + if (equalsPos > 0 && "!<>=+-*/%&|^".indexOf(text.charAt(equalsPos - 1)) >= 0) { + pos = equalsPos + 1; + continue; + } + if (equalsPos < text.length() - 1 && text.charAt(equalsPos + 1) == '=') { + pos = equalsPos + 2; + continue; + } + + // Find the start of this statement (previous ; or { or } or start of text) + int stmtStart = equalsPos - 1; + while (stmtStart >= 0) { + char c = text.charAt(stmtStart); + if (c == ';' || c == '{' || c == '}') { + stmtStart++; + break; + } + stmtStart--; + } + if (stmtStart < 0) stmtStart = 0; + + // Skip leading whitespace to get to the actual first character of the statement + while (stmtStart < equalsPos && Character.isWhitespace(text.charAt(stmtStart))) { + stmtStart++; + } + + // Find the end of this statement (next ;) + int stmtEnd = text.indexOf(';', equalsPos); + if (stmtEnd < 0) stmtEnd = text.length(); + + // Extract LHS (before =) and RHS (after =) + String lhsRaw = text.substring(stmtStart, equalsPos).trim(); + String rhsRaw = text.substring(equalsPos + 1, stmtEnd).trim(); + + // Skip empty assignments + if (lhsRaw.isEmpty() || rhsRaw.isEmpty()) { + pos = equalsPos + 1; + continue; + } + + // Check if this is a declaration (has type before variable name) + // Declarations have: Type varName = expr + // Reassignments have: varName = expr or obj.field = expr + boolean isDeclaration = isVariableDeclaration(lhsRaw); + + if (isDeclaration) { + // This is a declaration with initializer - validate the initial value + createAndAttachDeclarationAssignment(lhsRaw, rhsRaw, stmtStart, equalsPos, stmtEnd); + } else { + // This is a reassignment - create AssignmentInfo and attach to FieldInfo + createAndAttachAssignment(lhsRaw, rhsRaw, stmtStart, equalsPos, stmtEnd); + } + + pos = stmtEnd + 1; + } + } + + /** + * Check if the LHS represents a variable declaration (has a type before the variable name). + */ + private boolean isVariableDeclaration(String lhs) { + // Split by whitespace + String[] parts = lhs.trim().split("\\s+"); + + // Single word = reassignment (e.g., "x") + if (parts.length == 1) { + // Could also be a.b = expr (field access) + return false; + } + + // Check if it matches pattern: [modifiers] Type varName + // Last part is the variable name, second-to-last should be a type + String potentialType = parts[parts.length - 2]; + String potentialVar = parts[parts.length - 1]; + + // Type patterns: primitives, or capitalized class names, or generic types + if (TypeResolver.isPrimitiveType(potentialType) || + (Character.isUpperCase(potentialType.charAt(0)) && potentialType.matches("[A-Za-z_][A-Za-z0-9_<>\\[\\],\\s]*")) || + potentialType.equals("var") || potentialType.equals("let") || potentialType.equals("const")) { + + // Make sure the variable name is a valid identifier (not a field access chain) + if (potentialVar.matches("[a-zA-Z_][a-zA-Z0-9_]*") && !potentialVar.contains(".")) { + return true; + } + } + + return false; + } + + /** + * Create an AssignmentInfo for a reassignment statement and attach it to the appropriate FieldInfo. + */ + private void createAndAttachAssignment(String lhs, String rhs, int stmtStart, int equalsPos, int stmtEnd) { + lhs = lhs.trim(); + rhs = rhs.trim(); + + // Parse the target (LHS) + // Could be: varName or obj.field or this.field or array[index] + String targetName = lhs; + FieldInfo targetField = null; + TypeInfo targetType = null; + TypeInfo receiverType = null; + java.lang.reflect.Field reflectionField = null; + + // Find the position where the LHS starts in the actual text (skip leading whitespace) + int lhsStart = stmtStart; + while (lhsStart < equalsPos && Character.isWhitespace(text.charAt(lhsStart))) { + lhsStart++; + } + + // Calculate the actual LHS end (before '=' minus trailing whitespace) + int lhsEnd = equalsPos; + while (lhsEnd > lhsStart && Character.isWhitespace(text.charAt(lhsEnd - 1))) { + lhsEnd--; + } + + // Calculate RHS start (first non-whitespace after '=') + int rhsStart = equalsPos + 1; + while (rhsStart < stmtEnd && Character.isWhitespace(text.charAt(rhsStart))) { + rhsStart++; + } + + // RHS end is at the semicolon position (inclusive for error underline) + int rhsEnd = stmtEnd; + + // Handle chained field access (obj.field) + if (lhs.contains(".")) { + // Split into segments + String[] segments = lhs.split("\\."); + targetName = segments[segments.length - 1].trim(); + + // Resolve the chain to get the target type + String receiverExpr = lhs.substring(0, lhs.lastIndexOf('.')).trim(); + receiverType = resolveExpressionType(receiverExpr, lhsStart); + + if (receiverType != null && receiverType.isResolved()) { + // Get the field from the receiver type + if (receiverType.hasField(targetName)) { + targetField = receiverType.getFieldInfo(targetName); + targetType = targetField.getTypeInfo(); + reflectionField = targetField.getReflectionField(); + } + } + // Minecraft.getMinecraft().thePlayer.PERSISTED_NBT_TAG = null; + + } else { + // Simple variable + targetField = resolveVariable(targetName, lhsStart); + if (targetField != null) { + targetType = targetField.getTypeInfo(); + reflectionField = targetField.getReflectionField(); + } + } + + // Resolve the source type (RHS) with expected type context + TypeInfo sourceType; + ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = targetType; + try { + sourceType = resolveExpressionType(rhs, equalsPos + 1); + } finally { + ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = null; + } + + // 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) + if (targetField != null && targetField.canInferType() && targetField.getInferredType() == null + && sourceType != null && sourceType.isResolved()) { + // Don't infer "any" type - that doesn't refine anything + if (!"any".equals(sourceType.getFullName())) { + targetField.setInferredType(sourceType); + } + } + + // Determine if this is a script-defined field or external field + FieldInfo finalTargetField = targetField; + boolean isScriptField = targetField != null && + (globalFields.containsValue(targetField) || + methodLocals.values().stream().anyMatch(m -> m.containsValue(finalTargetField))); + + // Determine if the target field is final + // For script fields, use the modifiers; for external fields, use reflection + boolean isFinal = false; + if (targetField != null) { + isFinal = targetField.isFinal(); + } + + // Create the assignment info using the new constructor + AssignmentInfo info = new AssignmentInfo( + targetName, + stmtStart, + lhsStart, + lhsEnd, + targetType, + rhsStart, + rhsEnd, + sourceType, + rhs, + receiverType, + reflectionField, + isFinal + ); + + // Validate the assignment + info.validate(); + + // Attach to the appropriate location + if (isScriptField && targetField != null) { + // Script-defined field - attach to FieldInfo + targetField.addAssignment(info); + } else { + // External field or unresolved - store separately + externalFieldAssignments.add(info); + } + } + + /** + * Create an AssignmentInfo for a declaration with initializer and attach it to the appropriate FieldInfo. + * Example: String str = 20; or final int x = "test"; + */ + private void createAndAttachDeclarationAssignment(String lhs, String rhs, int stmtStart, int equalsPos, int stmtEnd) { + lhs = lhs.trim(); + rhs = rhs.trim(); + + // Parse the declaration: [modifiers] Type varName + String[] parts = lhs.split("\\s+"); + if (parts.length < 2) { + return; // Invalid declaration format + } + + // Last part is the variable name + String varName = parts[parts.length - 1].trim(); + + // Find the actual position of the variable name in the text + int varNameStart = stmtStart; + int searchStart = stmtStart; + while (searchStart < equalsPos) { + int found = text.indexOf(varName, searchStart); + if (found >= 0 && found < equalsPos) { + // Verify it's the actual variable name (not part of type name) + // Check that it's either at start or preceded by whitespace + if (found == stmtStart || Character.isWhitespace(text.charAt(found - 1))) { + // Check that it's followed by whitespace or '=' + int afterVar = found + varName.length(); + if (afterVar >= text.length() || Character.isWhitespace(text.charAt(afterVar)) || text.charAt(afterVar) == '=') { + varNameStart = found; + break; + } + } + searchStart = found + 1; + } else { + break; + } + } + + int varNameEnd = varNameStart + varName.length(); + + // Calculate RHS positions + int rhsStart = equalsPos + 1; + while (rhsStart < stmtEnd && Character.isWhitespace(text.charAt(rhsStart))) { + rhsStart++; + } + int rhsEnd = stmtEnd; + + // Resolve the target field (should already exist from parseGlobalFields or parseLocalVariables) + FieldInfo targetField = resolveVariable(varName, varNameStart); + if (targetField == null || targetField.getDeclarationAssignment() != null) { + return; // Field doesn't exist, can't attach assignment + } + + TypeInfo targetType = targetField.getTypeInfo(); + + // Resolve the source type with expected type context + TypeInfo sourceType; + ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = targetType; + try { + sourceType = resolveExpressionType(rhs, rhsStart); + } finally { + ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = null; + } + + // Type inference: If the target field has "any" type and no inferred type yet, + // set the inferred type from the resolved source type + if (targetField.canInferType() && targetField.getInferredType() == null && sourceType != null && sourceType.isResolved()) { + // Don't infer "any" type - that doesn't refine anything + if (!"any".equals(sourceType.getFullName())) { + targetField.setInferredType(sourceType); + } + } + + // Create the assignment info + // Declaration assignments should NOT check final status - this is the one place where final fields can be assigned + AssignmentInfo info = new AssignmentInfo( + varName, + stmtStart, + varNameStart, + varNameEnd, + targetType, + rhsStart, + rhsEnd, + sourceType, + rhs, + null, // No receiver for simple declarations + targetField.getReflectionField(), + false // Don't flag as final for declaration assignments - initial assignment is always allowed + ); + + // Validate the assignment + info.validate(); + + // Attach as the declaration assignment + targetField.setDeclarationAssignment(info); + } + + // ==================== METHOD INHERITANCE DETECTION ==================== + + /** + * Detect method overrides and interface implementations for all script-defined types. + * This analyzes each method in each ScriptTypeInfo to determine if it: + * - Overrides a method from a parent class (extends) + * - Implements a method from an interface (implements) + * + * The detection uses signature matching (method name + parameter types). + * Also validates that all interface methods are implemented and constructors match. + */ + private void detectMethodInheritance() { + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + scriptType.clearErrors(); // Clear previous errors + detectMethodInheritanceForType(scriptType); + scriptType.validate(); // Validate after detecting inheritance + } + } + + /** + * Detect method inheritance for a single script type. + */ + private void detectMethodInheritanceForType(ScriptTypeInfo scriptType) { + // Check each method in this type + for (List overloads : scriptType.getMethods().values()) { + for (MethodInfo method : overloads) { + // Check if method overrides a parent class method + if (scriptType.hasSuperClass()) { + TypeInfo superClass = scriptType.getSuperClass(); + if (superClass != null && superClass.isResolved()) { + TypeInfo overrideSource = findMethodInHierarchy(superClass, method, false); + if (overrideSource != null) { + method.setOverridesFrom(overrideSource); + } + } + } + + // Check if method implements an interface method (only if not already marked as override) + if (!method.isOverride() && scriptType.hasImplementedInterfaces()) { + for (TypeInfo iface : scriptType.getImplementedInterfaces()) { + if (iface != null && iface.isResolved()) { + TypeInfo implementsSource = findMethodInInterface(iface, method); + if (implementsSource != null) { + method.setImplementsFrom(implementsSource); + break; // Only mark first matching interface + } + } + } + } + } + } + } + + /** + * Search for a matching method in a type hierarchy (class inheritance). + * @param type The type to search in (and its superclasses) + * @param method The method to find a match for + * @param includeInterfaces Whether to also search interfaces + * @return The TypeInfo containing the matching method, or null if not found + */ + private TypeInfo findMethodInHierarchy(TypeInfo type, MethodInfo method, boolean includeInterfaces) { + if (type == null || !type.isResolved()) + return null; + + // Check if this type has the method + if (hasMatchingMethod(type, method)) { + return type; + } + + // If it's a ScriptTypeInfo, check its super class + if (type instanceof ScriptTypeInfo) { + ScriptTypeInfo scriptType = (ScriptTypeInfo) type; + if (scriptType.hasSuperClass()) { + TypeInfo result = findMethodInHierarchy(scriptType.getSuperClass(), method, includeInterfaces); + if (result != null) + return result; + } + } + + // If it's a Java class, check its superclass + Class javaClass = type.getJavaClass(); + if (javaClass != null) { + Class superClass = javaClass.getSuperclass(); + if (superClass != null && superClass != Object.class) { + TypeInfo superType = TypeInfo.fromClass(superClass); + TypeInfo result = findMethodInHierarchy(superType, method, includeInterfaces); + if (result != null) + return result; + } + } + + return null; + } + + /** + * Search for a matching method in an interface and its super-interfaces. + * @param iface The interface to search in + * @param method The method to find a match for + * @return The TypeInfo containing the matching method, or null if not found + */ + private TypeInfo findMethodInInterface(TypeInfo iface, MethodInfo method) { + if (iface == null || !iface.isResolved()) + return null; + + // Check if this interface has the method + if (hasMatchingMethod(iface, method)) { + return iface; + } + + // Check super-interfaces + Class javaClass = iface.getJavaClass(); + if (javaClass != null && javaClass.isInterface()) { + for (Class superIface : javaClass.getInterfaces()) { + TypeInfo superType = TypeInfo.fromClass(superIface); + TypeInfo result = findMethodInInterface(superType, method); + if (result != null) + return result; + } + } + + return null; + } + + /** + * Check if a type has a method matching the given signature. + * Uses method name and parameter types for matching. + */ + private boolean hasMatchingMethod(TypeInfo type, MethodInfo method) { + String methodName = method.getName(); + int paramCount = method.getParameterCount(); + + // For ScriptTypeInfo, check its methods map + if (type instanceof ScriptTypeInfo) { + ScriptTypeInfo scriptType = (ScriptTypeInfo) type; + List overloads = scriptType.getAllMethodOverloads(methodName); + for (MethodInfo candidate : overloads) { + if (signaturesMatch(method, candidate)) { + return true; + } + } + return false; + } + + // For Java classes, use reflection + Class javaClass = type.getJavaClass(); + if (javaClass == null) + return false; + + try { + for (java.lang.reflect.Method javaMethod : javaClass.getMethods()) { + if (javaMethod.getName().equals(methodName) && + javaMethod.getParameterCount() == paramCount) { + // Check parameter types match + if (parameterTypesMatch(method, javaMethod)) { + return true; + } + } + } + } catch (Exception e) { + // Security or linkage error + } + + return false; + } + + /** + * Check if two MethodInfo signatures match (same name and parameter types). + */ + private boolean signaturesMatch(MethodInfo m1, MethodInfo m2) { + if (!m1.getName().equals(m2.getName())) + return false; + if (m1.getParameterCount() != m2.getParameterCount()) + return false; + + List params1 = m1.getParameters(); + List params2 = m2.getParameters(); + + for (int i = 0; i < params1.size(); i++) { + TypeInfo type1 = params1.get(i).getDeclaredType(); + TypeInfo type2 = params2.get(i).getDeclaredType(); + + // Both null = match + if (type1 == null && type2 == null) + continue; + + // One null, one not = no match + if (type1 == null || type2 == null) + return false; + + // Compare full names + if (!type1.getFullName().equals(type2.getFullName())) { + return false; + } + } + + return true; + } + + /** + * Check if a MethodInfo's parameter types match a Java reflection Method's parameter types. + */ + private boolean parameterTypesMatch(MethodInfo methodInfo, java.lang.reflect.Method javaMethod) { + List params = methodInfo.getParameters(); + Class[] javaParams = javaMethod.getParameterTypes(); + + if (params.size() != javaParams.length) + return false; + + for (int i = 0; i < params.size(); i++) { + TypeInfo paramType = params.get(i).getDeclaredType(); + if (paramType == null) + continue; // Unresolved param, skip check + + Class javaParamClass = javaParams[i]; + String javaParamName = javaParamClass.getName(); + + // Compare type names + if (!paramType.getFullName().equals(javaParamName) && + !paramType.getSimpleName().equals(javaParamClass.getSimpleName())) { + return false; + } + } + + return true; + } + + /** + * Mark types in cast expressions with their appropriate type color. + * Handles: (Type), (Type[]), (pkg.Type), etc. + */ + private void markCastTypes(List marks) { + Matcher m = CAST_TYPE_PATTERN.matcher(text); + + while (m.find()) { + String typeName = m.group(1); + int typeStart = m.start(1); + int typeEnd = m.end(1); + + // Skip if inside string or comment + if (isExcluded(typeStart)) continue; + + // Skip in import/package statements + if (isInImportOrPackage(typeStart)) continue; + + // Resolve the type + TypeInfo info = resolveType(typeName); + if (info != null && info.isResolved()) { + // Mark the type with its appropriate color + marks.add(new ScriptLine.Mark(typeStart, typeEnd, info.getTokenType(), info)); + } else { + // Unknown type - check if it's a primitive + if (isPrimitiveType(typeName)) { + marks.add(new ScriptLine.Mark(typeStart, typeEnd, TokenType.KEYWORD)); + } else { + // Mark as undefined type + marks.add(new ScriptLine.Mark(typeStart, typeEnd, TokenType.UNDEFINED_VAR)); + } + } + } + } + + /** + * Check if a type name is a primitive type. + */ + private boolean isPrimitiveType(String typeName) { + switch (typeName) { + case "byte": case "short": case "int": case "long": + case "float": case "double": case "char": case "boolean": + case "void": + return true; + default: + return false; + } + } + + private void markImportedClassUsages(List marks) { + // Find uppercase identifiers followed by dot (static method calls, field access) + Pattern classUsage = Pattern.compile("\\b([A-Za-z][a-zA-Z0-9_]*)\\s*\\."); + Matcher m = classUsage.matcher(text); + + while (m.find()) { + String className = m.group(1); + int start = m.start(1); + int end = m.end(1); + + if (isExcluded(start)) + continue; + + // Skip in import/package statements + if (isInImportOrPackage(start)) + continue; + + // For JavaScript, first check synthetic types (Nashorn built-ins like Java, print, etc.) + if (isJavaScript() && typeResolver.isSyntheticType(className)) { + SyntheticType syntheticType = typeResolver.getSyntheticType(className); + // Mark as IMPORTED_CLASS since it's a type reference + // Pass the TypeInfo (which the hover system can display) + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.IMPORTED_CLASS, + syntheticType.getTypeInfo())); + continue; + } + + // Try to resolve the class + TypeInfo info = resolveType(className); + if (info != null && info.isResolved()) { + marks.add(new ScriptLine.Mark(start, end, info.getTokenType(), info)); + } else { + // Unknown type - mark as undefined + marks.add(new ScriptLine.Mark(start, end, TokenType.UNDEFINED_VAR)); + } + } + + // Also mark uppercase identifiers in type positions (new X(), X variable, etc.) + Pattern typeUsage = Pattern.compile("\\b(new\\s+)?([A-Za-z][a-zA-Z0-9_]*)(?:\\s*<[^>]*>)?\\s*(?:\\(|\\[|\\b[a-z])"); + Matcher tm = typeUsage.matcher(text); + + while (tm.find()) { + String className = tm.group(2); + String newKeyword = tm.group(1); + int start = tm.start(2); + int end = tm.end(2); + + if (isExcluded(start)) + continue; + + // Skip in import/package statements + if (isInImportOrPackage(start)) + continue; + + // Try to resolve the class + TypeInfo info = resolveType(className); + boolean isVariableHoldingClass = false; + + // If not a class name, check if it's a variable holding a ClassTypeInfo + if ((info == null || !info.isResolved()) && isJavaScript()) { + FieldInfo varInfo = resolveVariable(className, start); + if (varInfo != null && varInfo.getTypeInfo() instanceof ClassTypeInfo) { + // Variable holds a class reference (like var File = Java.type("java.io.File")) + ClassTypeInfo classRef = (ClassTypeInfo) varInfo.getTypeInfo(); + info = classRef.getInstanceType(); + isVariableHoldingClass = true; + } + } + + if (info != null && info.isResolved()) { + boolean isNewCreation = newKeyword != null && newKeyword.trim().equals("new"); + boolean isConstructorDecl = info instanceof ScriptTypeInfo && className.equals(info.getSimpleName()); + + // Check if this is a "new" expression or constructor declaration + if (isNewCreation || isConstructorDecl) { + // Find opening paren after the class name + int searchPos = end; + while (searchPos < text.length() && Character.isWhitespace(text.charAt(searchPos))) + searchPos++; + + if (searchPos < text.length() && text.charAt(searchPos) == '(') { + int openParen = searchPos; + int closeParen = findMatchingParen(openParen); + + if (closeParen >= 0) { + // For constructor declarations, verify it's followed by opening brace + if (isConstructorDecl && !isNewCreation) { + int braceSearch = closeParen + 1; + while (braceSearch < text.length() && Character.isWhitespace(text.charAt(braceSearch))) + braceSearch++; + + if (braceSearch >= text.length() || text.charAt(braceSearch) != '{') { + // Not a constructor declaration, treat as normal type usage + marks.add(new ScriptLine.Mark(start, end, info.getTokenType(), info)); + continue; + } + } + + // Parse arguments to find matching constructor + List arguments = parseMethodArguments(openParen + 1, closeParen, + null); + int argCount = arguments.size(); + TypeInfo[] argTypes = arguments.stream().map(MethodCallInfo.Argument::getResolvedType) + .toArray(TypeInfo[]::new); + + + // Try to find matching constructor (may be null if not found) + MethodInfo constructor = info.hasConstructors() ? info.findConstructor(argTypes) : null; + + // Create MethodCallInfo for constructor + // Use the actual variable name (className) for variables, not the class's simple name + MethodCallInfo ctorCall; + if (isVariableHoldingClass) { + // Use constructor directly with variable name + ctorCall = new MethodCallInfo( + className, // Use variable name, not class name + start, end, + openParen, closeParen, + arguments, + info, // The actual class type + constructor, + false + ).setConstructor(true); + } else { + // Use factory method for regular types + ctorCall = MethodCallInfo.constructor( + info, constructor, start, end, openParen, closeParen, arguments + ); + } + + ctorCall.validate(); + methodCalls.add(ctorCall); // Add to methodCalls list for error tracking + marks.add(new ScriptLine.Mark(start, end, info.getTokenType(), ctorCall)); + continue; + } + } + } + + marks.add(new ScriptLine.Mark(start, end, info.getTokenType(), info)); + } else { + // Unknown type - mark as undefined + marks.add(new ScriptLine.Mark(start, end, TokenType.UNDEFINED_VAR)); + } + } + } + + // ==================== PHASE 5: CONFLICT RESOLUTION ==================== + + private List resolveConflicts(List marks) { + // Sort by start position, then by descending priority + marks.sort((a, b) -> { + if (a.start != b.start) + return Integer.compare(a.start, b.start); + return Integer.compare(b.type.getPriority(), a.type.getPriority()); + }); + + List result = new ArrayList<>(); + for (ScriptLine.Mark m : marks) { + boolean skip = false; + + // Remove lower-priority overlapping marks + for (int i = result.size() - 1; i >= 0; i--) { + ScriptLine.Mark r = result.get(i); + if (m.start < r.end && m.end > r.start) { + if (r.type.getPriority() < m.type.getPriority()) { + result.remove(i); + } else { + skip = true; + break; + } + } + } + + if (!skip) { + result.add(m); + } + } + + result.sort(Comparator.comparingInt(m -> m.start)); + return result; + } + + // ==================== PHASE 7: INDENT GUIDES ==================== + + private void computeIndentGuides(List marks) { + for (ScriptLine line : lines) { + line.clearIndentGuides(); + } + + // Build ignored ranges from STRING and COMMENT marks + Set ignored = new HashSet<>(); + for (ScriptLine.Mark m : marks) { + if (m.type == TokenType.STRING || m.type == TokenType.COMMENT) { + ignored.add(new int[]{m.start, m.end}); + } + } + + class OpenBrace { + int lineIdx, col; + + OpenBrace(int l, int c) { + lineIdx = l; + col = c; + } + } + Deque stack = new ArrayDeque<>(); + final int tabSize = 4; + + for (int li = 0; li < lines.size(); li++) { + ScriptLine line = lines.get(li); + String s = line.getText(); + + for (int i = 0; i < s.length(); i++) { + int absPos = line.getGlobalStart() + i; + + // Check if in ignored range + boolean isIgnored = false; + for (int[] range : ignored) { + if (absPos >= range[0] && absPos < range[1]) { + isIgnored = true; + break; + } + } + if (isIgnored) + continue; + + char c = s.charAt(i); + if (c == '{') { + int leading = 0; + for (int k = 0; k < i; k++) { + char ch = s.charAt(k); + leading += (ch == '\t') ? tabSize : 1; + } + stack.push(new OpenBrace(li, leading)); + } else if (c == '}') { + if (!stack.isEmpty()) { + OpenBrace open = stack.pop(); + if (open.lineIdx == li) + continue; + + int from = Math.max(0, open.lineIdx + 1); + int to = Math.min(lines.size() - 1, li); + for (int l = from; l <= to; l++) { + lines.get(l).addIndentGuide(open.col); + } + } + } + } + } + } + + // ==================== UTILITY METHODS ==================== + + /** + * Helper to create and validate a FieldAccessInfo. Does NOT add marks or register the info — + * callers should add to `fieldAccesses` and `marks` as appropriate so marking logic remains in-place. + */ + FieldAccessInfo createFieldAccessInfo(String name, int start, int end, + TypeInfo receiverType, FieldInfo fieldInfo, + boolean isLastSegment, boolean isStaticAccess) { + FieldAccessInfo accessInfo = new FieldAccessInfo(name, start, end, receiverType, fieldInfo, isStaticAccess); + if (isLastSegment) { + TypeInfo expectedType = findExpectedTypeAtPosition(start); + if (expectedType != null) { + accessInfo.setExpectedType(expectedType); + } + } + accessInfo.validate(); + fieldAccesses.add(accessInfo); + return accessInfo; + } + + /** + * Resolve a variable by name at a given position. + * Works for BOTH Java and JavaScript using unified data structures. + * Checks innermost scope first (lambda or method), then walks up parent chain. + */ + public FieldInfo resolveVariable(String name, int position) { + // 1. Check innermost scope (lambda or method) + Object innermostScope = findInnermostScopeAt(position); + + if (innermostScope instanceof InnerCallableScope) { + InnerCallableScope scope = (InnerCallableScope) innermostScope; + + // Check lambda parameters and locals, walking up parent chain + InnerCallableScope currentScope = scope; + while (currentScope != null) { + // Check parameters + FieldInfo param = currentScope.getParameter(name); + if (param != null) { + return param; + } + + // Check locals + FieldInfo local = currentScope.getLocals().get(name); + if (local != null) { + return local; + } + + currentScope = currentScope.getParentScope(); + } + + // Not found in lambda scopes - fall back to enclosing method + // Find which method this lambda is inside + innermostScope = findMethodAtPosition(position); + } + + // 2. Check method scope (params + locals) + if (innermostScope instanceof MethodInfo) { + MethodInfo method = (MethodInfo) innermostScope; + + // Check method parameters + if (method.hasParameter(name)) { + return method.getParameter(name); + } + + // Check method locals + Map locals = methodLocals.get(method.getDeclarationOffset()); + if (locals != null && locals.containsKey(name)) { + FieldInfo localInfo = locals.get(name); + if (localInfo.isVisibleAt(position)) { + return localInfo; + } + } + } + + // 3. For Java only: Check if we're inside a script type and look for fields there + if (!isJavaScript()) { + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null && enclosingType.hasField(name)) { + return enclosingType.getFieldInfo(name); + } + } + + // 4. Check global fields (stores both Java global fields and JS global var/let/const) + if (globalFields.containsKey(name)) { + return globalFields.get(name); + } + + // 5. Check JS global objects from JSTypeRegistry (like API, DBCAPI) + if (isJavaScript()) { + JSTypeRegistry registry = JSTypeRegistry.getInstance(); + String globalObjectType = registry.getGlobalObjectType(name); + if (globalObjectType != null) { + // Resolve the type and create a FieldInfo for it + TypeInfo typeInfo = resolveType(globalObjectType); + if (typeInfo != null && typeInfo.isResolved()) { + // Create a global field for this object with GLOBAL scope + return FieldInfo.globalField(name, typeInfo, -1); + } + } + + // Check if is DataScript global variable definition + String editorGlobalType = editorGlobals.get(name); + if (editorGlobalType != null) { + TypeInfo typeInfo = resolveType(editorGlobalType); + if (typeInfo == null || !typeInfo.isResolved()) { + typeInfo = TypeInfo.ANY; + } + return FieldInfo.globalField(name, typeInfo, -1); + } + } + + return null; + } + + boolean isPrecededByDot(int position) { + if (position <= 0) + return false; + int i = position - 1; + while (i >= 0 && Character.isWhitespace(text.charAt(i))) + i--; + return i >= 0 && text.charAt(i) == '.'; + } + + boolean isInImportOrPackage(int position) { + if (position < 0 || position >= text.length()) + return false; + + int lineStart = text.lastIndexOf('\n', position); + lineStart = lineStart < 0 ? 0 : lineStart + 1; + + int i = lineStart; + while (i < text.length() && Character.isWhitespace(text.charAt(i))) + i++; + + if (text.startsWith("import", i) || text.startsWith("package", i)) { + return true; + } + return false; + } + + /** Check if position is followed by '(' (method call) */ + boolean isFollowedByParen(int pos) { + int check = skipWhitespace(pos); + return check < text.length() && text.charAt(check) == '('; + } + + /** Check if position is followed by '.' */ + boolean isFollowedByDot(int pos) { + int check = skipWhitespace(pos); + return check < text.length() && text.charAt(check) == '.'; + } + + /** Skip whitespace and return new position */ + int skipWhitespace(int pos) { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) + pos++; + return pos; + } + + private MethodInfo findMethodAtPosition(int position) { + for (MethodInfo method : getAllMethods()) { + if (method.containsPosition(position)) { + return method; + } + } + return null; + } + + /** + * Find the innermost scope (method or inner callable) at a position. + * Returns null if not inside any scope. + */ + public Object findInnermostScopeAt(int position) { + // First check inner callable scopes (most specific) + InnerCallableScope innermost = null; + for (InnerCallableScope scope : innerScopes) { + if (scope.containsPosition(position)) { + if (innermost == null || scope.getBodyStart() > innermost.getBodyStart()) { + innermost = scope; + } + } + } + + if (innermost != null) { + return innermost; + } + + // Fall back to method scope + return findMethodAtPosition(position); + } + + private int findMatchingBrace(int openBraceIndex) { + if (openBraceIndex < 0 || openBraceIndex >= text.length()) + return -1; + + int depth = 0; + for (int i = openBraceIndex; i < text.length(); i++) { + if (isExcluded(i)) + continue; + + char c = text.charAt(i); + if (c == '{') + depth++; + else if (c == '}') { + depth--; + if (depth == 0) + return i; + } + } + return -1; + } + + // ==================== ACCESSORS ==================== + + public List getLines() { + return Collections.unmodifiableList(lines); + } + + public List getImports() { + return Collections.unmodifiableList(imports); + } + + public List getMethods() { + return Collections.unmodifiableList(methods); + } + + public List getInnerScopes() { + return Collections.unmodifiableList(innerScopes); + } + + public List getScriptTypes() { + return scriptTypes.values().stream().collect(Collectors.toList()); + } + + public List getAllMethods() { + List allMethods = new ArrayList<>(methods); + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + allMethods.addAll(scriptType.getAllMethodsFlat()); + // Include constructors so their parameters are recognized + // allMethods.addAll(scriptType.getConstructors()); + } + + return allMethods; + } + + public List getAllConstructors() { + List allConstructors = new ArrayList<>(); + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + allConstructors.addAll(scriptType.getConstructors()); + } + return allConstructors; + } + + public List getMethodCalls() { + List allCalls = new ArrayList<>(methodCalls); + + for (EnumConstantInfo constant : getAllEnumConstants()) { + if (constant.getConstructorCall() != null) + allCalls.add(constant.getConstructorCall()); + } + + + return allCalls; + } + + public List getFieldAccesses() { + return Collections.unmodifiableList(fieldAccesses); + } + + public List getAllEnumConstants() { + List enums = new ArrayList<>(); + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + if (!scriptType.hasEnumConstants()) + continue; + + enums.addAll(scriptType.getEnumConstants().values()); + } + return enums; + } + /** + * Find an assignment at the given position, prioritizing LHS over RHS. + * Searches across all script fields and external field assignments. + * Used by TokenHoverInfo for finding assignment errors. + */ + public AssignmentInfo findAssignmentAtPosition(int position) { + // Check script fields (FieldInfo.findAssignmentAtPosition already handles LHS/RHS priority) + for (FieldInfo field : globalFields.values()) { + AssignmentInfo assign = field.findAssignmentAtPosition(position); + if (assign != null) { + return assign; + } + } + + for (Map locals : methodLocals.values()) { + for (FieldInfo field : locals.values()) { + AssignmentInfo assign = field.findAssignmentAtPosition(position); + if (assign != null) { + return assign; + } + } + } + + for (AssignmentInfo assign : declarationErrors) { + if (assign.containsLhsPosition(position)) { + return assign; + } + } + + // Check external field assignments with same LHS-first priority + for (AssignmentInfo assign : externalFieldAssignments) { + if (assign.containsLhsPosition(position)) { + return assign; + } + } + + for (AssignmentInfo assign : externalFieldAssignments) { + if (assign.containsRhsPosition(position)) { + return assign; + } + } + + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + for (FieldInfo field : scriptType.getFields().values()) { + AssignmentInfo assign = field.findAssignmentAtPosition(position); + if (assign != null) + return assign; + } + } + + return null; + } + + public Map getGlobalFields() { + return Collections.unmodifiableMap(globalFields); + } + + /** + * Get all variables available at a specific position. + * Used by autocomplete to show scope-aware suggestions. + * Returns variables from innermost scope first (parameters, then locals), + * walking up through parent scopes, then method scope, then globals. + * + * @param position The cursor position + * @return List of FieldInfo for all available variables, ordered by priority + */ + public List getAvailableVariablesAt(int position) { + List variables = new ArrayList<>(); + + // Check innermost scope (lambda or method) + Object innermostScope = findInnermostScopeAt(position); + + if (innermostScope instanceof InnerCallableScope) { + InnerCallableScope scope = (InnerCallableScope) innermostScope; + + // Add lambda parameters and locals, walking up parent chain + InnerCallableScope currentScope = scope; + while (currentScope != null) { + // Add parameters from this scope + variables.addAll(currentScope.getParameters()); + + // Add locals from this scope (only those visible at position) + for (FieldInfo local : currentScope.getLocals().values()) { + if (local.isVisibleAt(position)) { + variables.add(local); + } + } + + currentScope = currentScope.getParentScope(); + } + + // Fall back to enclosing method + innermostScope = findMethodAtPosition(position); + } + + // Add method scope variables + if (innermostScope instanceof MethodInfo) { + MethodInfo method = (MethodInfo) innermostScope; + + // Add method parameters + variables.addAll(method.getParameters()); + + // Add method locals (only those visible at position) + Map locals = methodLocals.get(method.getDeclarationOffset()); + if (locals != null) { + for (FieldInfo local : locals.values()) { + if (local.isVisibleAt(position)) { + variables.add(local); + } + } + } + } + + // Add global fields (only those visible at position) + for (FieldInfo globalField : globalFields.values()) { + if (globalField.isVisibleAt(position)) { + variables.add(globalField); + } + } + + // For Java: add fields from enclosing type + if (!isJavaScript()) { + ScriptTypeInfo enclosingType = findEnclosingScriptType(position); + if (enclosingType != null) { + for (FieldInfo field : enclosingType.getFields().values()) { + if (field.isVisibleAt(position)) { + variables.add(field); + } + } + } + } + + // For JS: add global engine objects and editor globals + if (isJavaScript()) { + JSTypeRegistry registry = JSTypeRegistry.getInstance(); + + // Add global engine objects (like API, DBCAPI) + for (String globalName : registry.getGlobalEngineObjects().keySet()) { + FieldInfo field = resolveVariable(globalName, position); + if (field != null && field.isResolved()) { + variables.add(field); + } + } + + // Add editor/DataScript global variables + for (String globalName : editorGlobals.keySet()) { + FieldInfo field = resolveVariable(globalName, position); + if (field != null && field.isResolved()) { + variables.add(field); + } + } + } + + return variables; + } + + /** + * Get all errored assignments across all fields (global, local, and external). + * Used by ScriptLine to draw error underlines. + */ + public List getAllErroredAssignments() { + List errored = new ArrayList<>(); + + // Check global fields + for (FieldInfo field : globalFields.values()) { + errored.addAll(field.getErroredAssignments()); + } + + // Check method locals + for (Map locals : methodLocals.values()) { + for (FieldInfo field : locals.values()) { + errored.addAll(field.getErroredAssignments()); + } + } + + // Check external field assignments + for (AssignmentInfo assign : externalFieldAssignments) { + if (assign.hasError()) { + errored.add(assign); + } + } + + + for (ScriptTypeInfo scriptType : scriptTypes.values()) { + for (FieldInfo field : scriptType.getFields().values()) { + errored.addAll(field.getErroredAssignments()); + } + } + + + // Include declaration errors (duplicates, etc.) + errored.addAll(declarationErrors); + + return errored; + } + + public TypeResolver getTypeResolver() { + return typeResolver; + } + + public ScriptLine getLine(int index) { + for (ScriptLine line : lines) { + if (line.getLineIndex() == index) { + return line; + } + } + return null; + } + + public ScriptLine getLineAt(int globalPosition) { + for (ScriptLine line : lines) { + if (line.containsPosition(globalPosition)) { + return line; + } + } + return null; + } + + public int getLineIndexAt(int globalPosition) { + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).containsPosition(globalPosition)) { + return i; + } + } + return -1; + } + + /** + * Get all tokens that fall within a given range [start, end). + * Returns tokens in order. Tokens that span across the range boundaries + * are included if any part of them is within the range. + */ + public List getTokensInRange(int start, int end) { + List result = new ArrayList<>(); + if (start < 0 || end <= start) return result; + + for (ScriptLine line : lines) { + // Skip lines entirely before the range + if (line.getGlobalEnd() <= start) continue; + // Stop if line is entirely after the range + if (line.getGlobalStart() >= end) break; + + for (Token token : line.getTokens()) { + // Check if token overlaps with range + if (token.getGlobalEnd() > start && token.getGlobalStart() < end) { + result.add(token); + } + } + } + return result; + } + + /** + * Find the expected type for an expression at the given position by looking for assignment context. + * Returns the type of the variable being assigned to, or null if not in an assignment. + * + * Examples: + * - "Type varName = expr;" -> returns Type + * - "varName = expr;" -> returns type of varName + */ + public TypeInfo findExpectedTypeAtPosition(int position) { + // Walk backward from position to find '=' + int pos = position - 1; + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) { + pos--; + } + + // Look for '=' that precedes this position + int equalsPos = -1; + int depth = 0; // Track parentheses/brackets depth + for (int i = pos; i >= 0; i--) { + if (isExcluded(i)) continue; + + char c = text.charAt(i); + if (c == ')' || c == ']') depth++; + else if (c == '(' || c == '[') depth--; + else if (c == '=' && depth == 0) { + // Make sure it's not ==, !=, <=, >= + if (i > 0 && "!<>=".indexOf(text.charAt(i - 1)) >= 0) continue; + if (i < text.length() - 1 && text.charAt(i + 1) == '=') continue; + equalsPos = i; + break; + } + else if (c == ';' || c == '{' || c == '}') { + // Reached statement boundary without finding assignment + break; + } + } + + if (equalsPos < 0) { + return null; // Not in an assignment + } + + // Now parse the left-hand side of the assignment + // Could be: "Type varName = expr" or "varName = expr" + pos = equalsPos - 1; + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) { + pos--; + } + + if (pos < 0) return null; + + // Find the variable name (work backward to find identifier) + int varNameEnd = pos + 1; + int varNameStart = pos; + while (varNameStart >= 0 && (Character.isJavaIdentifierPart(text.charAt(varNameStart)))) { + varNameStart--; + } + varNameStart++; // Move to first char of identifier + + if (varNameStart >= varNameEnd) return null; + + String varName = text.substring(varNameStart, varNameEnd); + + // Now check if there's a type declaration before the variable name + pos = varNameStart - 1; + while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) { + pos--; + } + + if (pos < 0) { + // Just "varName = expr" - look up varName in scope + return lookupVariableType(varName, position); + } + + // Check if this could be a type declaration (look for identifier before varName) + int typeEnd = pos + 1; + int typeStart = pos; + while (typeStart >= 0 && (Character.isJavaIdentifierPart(text.charAt(typeStart)) || + text.charAt(typeStart) == '<' || text.charAt(typeStart) == '>' || + text.charAt(typeStart) == '[' || text.charAt(typeStart) == ']' || + text.charAt(typeStart) == ',')) { + typeStart--; + } + typeStart++; + + if (typeStart >= typeEnd) { + // Just "varName = expr" - look up varName + return lookupVariableType(varName, position); + } + + String typeStr = text.substring(typeStart, typeEnd).trim(); + + // Check if this is a var/let/const keyword (type inference) + if (typeStr.equals("var") || typeStr.equals("let") || typeStr.equals("const")) { + // Can't determine expected type from var/let/const + return null; + } + + // Resolve the type + return resolveType(typeStr); + } + + /** + * Look up the type of a variable by name at the given position. + */ + private TypeInfo lookupVariableType(String varName, int position) { + // Check method locals + MethodInfo containingMethod = findMethodAtPosition(position); + if (containingMethod != null) { + Map locals = methodLocals.get(containingMethod.getDeclarationOffset()); + if (locals != null && locals.containsKey(varName)) { + FieldInfo field = locals.get(varName); + if (field.isVisibleAt(position)) { + return field.getTypeInfo(); + } + } + } + + // Check global fields + if (globalFields.containsKey(varName)) { + return globalFields.get(varName).getTypeInfo(); + } + + return null; + } + + // ==================== AUTOCOMPLETE SUPPORT ==================== + + /** + * Find the method that contains the given position. + * Public accessor for autocomplete. + */ + public MethodInfo findContainingMethod(int position) { + return findMethodAtPosition(position); + } + + /** + * Get local variables for a specific method. + * Used by autocomplete to show variables in scope. + */ + public Map getLocalsForMethod(MethodInfo method) { + if (method == null) return null; + return methodLocals.get(method.getDeclarationOffset()); + } + + /** + * Get all imported types that have been resolved. + * Used by autocomplete to show available types. + */ + public List getImportedTypes() { + List types = new ArrayList<>(); + for (ImportData imp : imports) { + if (!imp.isWildcard() && imp.isResolved()) { + TypeInfo type = typeResolver.resolveSimpleName(imp.getSimpleName(), importsBySimpleName, wildcardPackages); + if (type != null && type.isResolved()) { + types.add(type); + } + } + } + return types; + } + + /** + * Get script types as a Map (needed by autocomplete). + */ + public Map getScriptTypesMap() { + return Collections.unmodifiableMap(scriptTypes); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java new file mode 100644 index 000000000..f3deb5ecf --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java @@ -0,0 +1,431 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.Token; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenErrorMessage; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.ImportData; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; + +/** + * Represents a single line of source code with its tokens. + * Handles token management, navigation, and rendering. + * + * A ScriptLine is responsible for: + * - Storing tokens that fall within this line's character range + * - Managing token navigation (prev/next within line) + * - Rendering the line with proper syntax highlighting + * - Computing indent guides for this line + */ +public class ScriptLine { + + private static final char COLOR_CHAR = '\u00A7'; + + private final String text; // The actual text of this line + public final int globalStart; // Start offset in the full document + public final int globalEnd; // End offset in the full document (exclusive) + public final int lineIndex; // 0-based line number + + private final List tokens = new ArrayList<>(); + private final List indentGuides = new ArrayList<>(); // Column positions for indent guides + + // Navigation + private ScriptLine prev; + private ScriptLine next; + private ScriptDocument parent; + + public ScriptLine(String text, int globalStart, int globalEnd, int lineIndex) { + this.text = text; + this.globalStart = globalStart; + this.globalEnd = globalEnd; + this.lineIndex = lineIndex; + } + + // ==================== GETTERS ==================== + + public String getText() { return text; } + public int getGlobalStart() { return globalStart; } + public int getGlobalEnd() { return globalEnd; } + public int getLineIndex() { return lineIndex; } + public int getLength() { return text.length(); } + public List getTokens() { return Collections.unmodifiableList(tokens); } + public List getIndentGuides() { return Collections.unmodifiableList(indentGuides); } + public ScriptDocument getParent() { return parent; } + + void setParent(ScriptDocument parent) { this.parent = parent; } + void setPrev(ScriptLine prev) { this.prev = prev; } + void setNext(ScriptLine next) { this.next = next; } + + // ==================== NAVIGATION ==================== + + public ScriptLine prev() { return prev; } + public ScriptLine next() { return next; } + + public Token getFirstToken() { + return tokens.isEmpty() ? null : tokens.get(0); + } + + public Token getLastToken() { + return tokens.isEmpty() ? null : tokens.get(tokens.size() - 1); + } + + public Token getTokenAt(int globalPosition) { + for (Token t : tokens) + if (globalPosition >= t.getGlobalStart() && globalPosition <= t.getGlobalEnd()) + return t; + + return null; + } + + public Token getTokenAt(int globalPosition, Predicate condition) { + for (Token t : tokens) + if (globalPosition >= t.getGlobalStart() && globalPosition <= t.getGlobalEnd() && condition.test(t)) + return t; + + return null; + + } + + // ==================== TOKEN MANAGEMENT ==================== + + /** + * Clear all tokens from this line. + */ + public void clearTokens() { + tokens.clear(); + } + + /** + * Add a token to this line and set up navigation links. + */ + public void addToken(Token token) { + if (!tokens.isEmpty()) { + Token last = tokens.get(tokens.size() - 1); + last.setNext(token); + token.setPrev(last); + } + token.setParentLine(this); + tokens.add(token); + } + + /** + * Build tokens from a list of marks (highlight regions). + * Fills gaps between marks with DEFAULT tokens. + * Attaches metadata from marks to the created tokens. + * + * @param marks List of highlight marks that overlap this line + * @param fullText The complete document text + * @param document The parent ScriptDocument (for context lookups) + */ + public void buildTokensFromMarks(List marks, String fullText, ScriptDocument document) { + clearTokens(); + int cursor = globalStart; + + for (Mark mark : marks) { + // Skip marks that don't overlap this line + if (mark.end <= globalStart || mark.start >= globalEnd) { + continue; + } + + // Clamp mark to line boundaries + int tokenStart = Math.max(mark.start, globalStart); + int tokenEnd = Math.min(mark.end, globalEnd); + + // Validate bounds + tokenStart = Math.max(0, Math.min(tokenStart, fullText.length())); + tokenEnd = Math.max(0, Math.min(tokenEnd, fullText.length())); + + // Add default token for any gap before this mark + if (cursor < tokenStart) { + int gapEnd = Math.min(tokenStart, fullText.length()); + String gapText = fullText.substring(cursor, gapEnd); + addToken(Token.defaultToken(gapText, cursor, gapEnd)); + } + + // Add the marked token with metadata + if (tokenStart < tokenEnd) { + String tokenText = fullText.substring(tokenStart, tokenEnd); + Token token = new Token(tokenText, tokenStart, tokenEnd, mark.type); + + // Attach metadata based on type + if (mark.metadata != null) + applyTokenMetadata(token, mark.metadata); + + addToken(token); + } + + cursor = tokenEnd; + } + + // Add trailing default token if needed + if (cursor < globalEnd) { + int end = Math.min(globalEnd, fullText.length()); + if (cursor < end) { + String trailingText = fullText.substring(cursor, end); + addToken(Token.defaultToken(trailingText, cursor, end)); + } + } + } + + public void applyTokenMetadata(Token token, Object metadata) { + if (metadata instanceof TypeInfo) { + token.setTypeInfo((TypeInfo) metadata); + } else if (metadata instanceof MethodCallInfo) { + MethodCallInfo callInfo = (MethodCallInfo) metadata; + if (callInfo.isConstructor()) { + token.setTypeInfo(callInfo.getReceiverType()); + token.setMethodInfo(callInfo.getResolvedMethod()); + } + token.setMethodCallInfo(callInfo); + } else if (metadata instanceof FieldInfo.ArgInfo) { + FieldInfo.ArgInfo ctx = (FieldInfo.ArgInfo) metadata; + token.setFieldInfo(ctx.fieldInfo); + token.setMethodCallInfo(ctx.methodCallInfo); + } else if (metadata instanceof FieldInfo) { + token.setFieldInfo((FieldInfo) metadata); + } else if (metadata instanceof MethodInfo) { + token.setMethodInfo((MethodInfo) metadata); + } else if (metadata instanceof ImportData) { + token.setImportData((ImportData) metadata); + } else if (metadata instanceof FieldAccessInfo) { + FieldAccessInfo accessInfo = (FieldAccessInfo) metadata; + token.setFieldAccessInfo(accessInfo); + token.setFieldInfo(accessInfo.getResolvedField()); + } else if (metadata instanceof TokenErrorMessage) { + token.setErrorMessage((TokenErrorMessage) metadata); + } + } + // ==================== INDENT GUIDES ==================== + + /** + * Clear indent guides for this line. + */ + public void clearIndentGuides() { + indentGuides.clear(); + } + + /** + * Add an indent guide at the specified column. + */ + public void addIndentGuide(int column) { + if (!indentGuides.contains(column)) { + indentGuides.add(column); + } + } + + // ==================== RENDERING ==================== + + /** + * Draw this line with syntax highlighting using Minecraft color codes. + * Compatible with the existing rendering system. + * Also draws curly underlines for tokens with errors (method call validation failures). + * Supports bold (§l) and italic (§o) formatting for certain token types. + */ + public void drawString(int x, int y, int defaultColor) { + StringBuilder builder = new StringBuilder(); + int lastIndex = 0; + + // Track positions for underlines + int currentX = x; + + for (Token t : tokens) { + int tokenStart = t.getGlobalStart() - globalStart; // relative position in line + int tokenWidth = ClientProxy.Font.width(t.getText()); + + // Calculate gap width before this token + if (tokenStart > lastIndex && tokenStart <= text.length()) { + String gapText = text.substring(lastIndex, tokenStart); + builder.append(gapText); + currentX += ClientProxy.Font.width(gapText); + } + + + // Append style codes (bold/italic) if applicable + String stylePrefix = t.getStylePrefix(); + + // Append the colored token with style + builder.append(COLOR_CHAR) + .append(t.getColorCode()) + .append(stylePrefix) + .append(t.getText()) + .append(COLOR_CHAR) + .append('r'); // Reset all formatting (color + bold/italic) + + currentX += tokenWidth; + lastIndex = tokenStart + t.getText().length(); + } + + // Append any remaining text after the last token + if (lastIndex < text.length()) { + builder.append(text.substring(lastIndex)); + } + + // Draw the text + ClientProxy.Font.drawString(builder.toString(), x, y, defaultColor); +// Minecraft.getMinecraft().fontRenderer.drawString(builder.toString(), x, y, defaultColor); + + + // Draw underlines for validation errors (method calls and field accesses) + drawErrorUnderlines(x, y + ClientProxy.Font.height() - 1); + } + /** + * Draw this line with hex colors instead of Minecraft color codes. + * More flexible but requires custom font rendering. + * Also draws wavy underlines for tokens with errors. + */ + public void drawStringHex(int x, int y) { + // Build the complete text with all tokens and gaps, draw it as ONE string + // to match the spacing behavior of drawString which draws everything at once + StringBuilder fullText = new StringBuilder(); + int lastIndex = 0; + + // Build segments with token position info + java.util.List segments = new java.util.ArrayList<>(); + + for (Token t : tokens) { + int tokenStart = t.getGlobalStart() - globalStart; + + // Add any gap before this token + if (tokenStart > lastIndex && tokenStart <= text.length()) { + String gap = text.substring(lastIndex, tokenStart); + segments.add(new TextSegment(fullText.length(), gap, 0xFFFFFFFF, false)); + fullText.append(gap); + } + + // Add the colored token + String styledText = t.getStylePrefix() + t.getText(); + segments.add(new TextSegment(fullText.length(), styledText, t.getHexColor(), true)); + fullText.append(styledText); + + lastIndex = tokenStart + styledText.length(); + } + + // Add any remaining text after the last token + if (lastIndex < text.length()) { + String remaining = text.substring(lastIndex); + segments.add(new TextSegment(fullText.length(), remaining, 0xFFFFFFFF, false)); + fullText.append(remaining); + } + + // Draw each segment at the correct position + // Calculate positions based on the full string to match drawString's spacing + for (TextSegment seg : segments) { + if (!seg.text.isEmpty()) { + // Get the width of everything before this segment + String prefix = fullText.substring(0, seg.startPos); + int prefixWidth = ClientProxy.Font.width(prefix); + int color = (seg.color & 0xFF000000) == 0 ? (0xFF000000 | seg.color) : seg.color; + + // Draw this segment at the correct position + ClientProxy.Font.drawString(seg.text, x + prefixWidth, y, color); + // Minecraft.getMinecraft().fontRenderer.drawString(seg.text, x + prefixWidth, y, color); + } + } + + drawErrorUnderlines(x, y + ClientProxy.Font.height() - 1); + } + + /** + * Draw underlines for all validation errors (method calls and field accesses) that intersect this line. + * Delegates to ErrorUnderlineRenderer for the actual rendering logic. + */ + private void drawErrorUnderlines(int lineStartX, int baselineY) { + if (parent == null) + return; + + ErrorUnderlineRenderer.drawErrorUnderlines( + parent, + lineStartX, + baselineY, + getText(), + getGlobalStart(), + getGlobalEnd() + ); + } + + + // Helper class to track text segments + private static class TextSegment { + final int startPos; + final String text; + final int color; + final boolean isToken; + + TextSegment(int startPos, String text, int color, boolean isToken) { + this.startPos = startPos; + this.text = text; + this.color = color; + this.isToken = isToken; + } + } + // ==================== UTILITIES ==================== + + /** + * Check if a global position falls within this line. + */ + public boolean containsPosition(int globalPosition) { + return globalPosition >= globalStart && globalPosition < globalEnd; + } + + /** + * Convert a global position to a column (local offset) within this line. + */ + public int toColumn(int globalPosition) { + return Math.max(0, Math.min(globalPosition - globalStart, text.length())); + } + + /** + * Convert a column (local offset) to a global position. + */ + public int toGlobal(int column) { + return globalStart + Math.max(0, Math.min(column, text.length())); + } + + @Override + public String toString() { + return "ScriptLine{" + lineIndex + ": '" + text + "' [" + globalStart + "-" + globalEnd + "], " + tokens.size() + " tokens}"; + } + + // ==================== MARK CLASS (for token building) ==================== + + /** + * A simple mark representing a highlighted region. + * Used during token building phase. + * Can optionally carry metadata (TypeInfo, FieldInfo, MethodInfo, ImportData). + */ + public static class Mark { + public final int start; + public final int end; + public final TokenType type; + public final Object metadata; + + public Mark(int start, int end, TokenType type) { + this.start = start; + this.end = end; + this.type = type; + this.metadata = null; + } + + public Mark(int start, int end, TokenType type, Object metadata) { + this.start = start; + this.end = end; + this.type = type; + this.metadata = metadata; + } + + @Override + public String toString() { + return "Mark{" + type + " [" + start + "-" + end + "]" + (metadata != null ? " " + metadata.getClass().getSimpleName() : "") + "}"; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java new file mode 100644 index 000000000..05da45227 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java @@ -0,0 +1,261 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.gui.util.script.JavaTextContainer; +import noppes.npcs.constants.ScriptContext; + +import java.util.Collections; +import java.util.Map; + +/** + * New interpreter-based text container that replaces JavaTextContainer. + * + * This class provides the same interface as JavaTextContainer but uses the + * new modular interpreter system internally. It produces compatible LineData + * objects for seamless integration with GuiScriptTextArea. + * + * Key differences from JavaTextContainer: + * - Clean single-pass tokenization with 7 distinct phases + * - Proper token navigation via prev()/next() methods + * - Type-specific metadata on tokens (TypeInfo, FieldInfo, MethodInfo) + * - Better separation of concerns (TypeResolver, ScriptDocument, ScriptLine) + * - Hex color support in addition to Minecraft color codes + */ +public class ScriptTextContainer extends JavaTextContainer { + + /** + * Feature flag to enable/disable the new interpreter system. + * When false, falls back to original JavaTextContainer behavior. + */ + public static boolean USE_NEW_INTERPRETER = true; + + private ScriptDocument document; + + /** The scripting language: "ECMAScript", "Groovy", etc. */ + private String language = "ECMAScript"; + + /** The script context: NPC, PLAYER, BLOCK, ITEM, etc. */ + private ScriptContext scriptContext = ScriptContext.GLOBAL; + + /** Editor-injected globals (name -> type name) */ + private Map editorGlobals = Collections.emptyMap(); + + public ScriptTextContainer(String text) { + super(text); + if (USE_NEW_INTERPRETER) { + document = new ScriptDocument(text); + } + } + + public ScriptTextContainer(String text, String language) { + super(text); + this.language = language != null ? language : "ECMAScript"; + if (USE_NEW_INTERPRETER) { + document = new ScriptDocument(text, this.language); + } + } + + /** + * Set the scripting language. This affects syntax highlighting and type inference. + * @param language The language name (e.g., "ECMAScript", "Groovy") + */ + public void setLanguage(String language) { + this.language = language != null ? language : "ECMAScript"; + if (document != null) { + document.setLanguage(this.language); + } + } + + /** + * Get the current scripting language. + */ + public String getLanguage() { + return language; + } + + /** + * Set the script context (NPC, PLAYER, BLOCK, ITEM, etc.). + * This determines which hooks and event types are available for autocomplete. + * + * @param context The script context + */ + public void setScriptContext(ScriptContext context) { + this.scriptContext = context != null ? context : ScriptContext.GLOBAL; + if (document != null) { + document.setScriptContext(this.scriptContext); + } + } + + /** + * Get the current script context. + * + * @return The script context (NPC, PLAYER, BLOCK, ITEM, etc.) + */ + public ScriptContext getScriptContext() { + return scriptContext; + } + + /** + * Set editor-injected globals (name -> type name). + */ + public void setEditorGlobalsMap(java.util.Map globals) { + this.editorGlobals = globals != null ? globals : java.util.Collections.emptyMap(); + if (document != null) { + document.setEditorGlobals(this.editorGlobals); + } + } + + /** + * Add implicit imports that should be resolved without explicit import statements. + * Used for JaninoScript default imports and hook parameter types. + * + * @param patterns Array of import patterns to add (wildcard packages like "noppes.npcs.api.*" or FQ class names) + */ + public void addImplicitImports(String... patterns) { + if (document != null) { + document.addImplicitImports(patterns); + } + } + + @Override + public void init(int width, int height) { + if (!USE_NEW_INTERPRETER) { + super.init(width, height); + return; + } + + lineHeight = ClientProxy.Font.height(); + if (lineHeight == 0) lineHeight = 12; + + // Initialize the document + document.setText(text); + document.init(width, height); + + // Convert ScriptLines to LineData for compatibility + rebuildLineData(); + + linesCount = lines.size(); + totalHeight = linesCount * lineHeight; + visibleLines = Math.max(height / lineHeight - 1, 1); + } + + /** + * Initialize with explicit text (called when text changes). + */ + @Override + public void init(String text, int width, int height) { + this.text = text == null ? "" : text.replaceAll("\\r?\\n|\\r", "\n"); + + if (!USE_NEW_INTERPRETER) { + super.init(text, width, height); + return; + } + + if (document == null) { + document = new ScriptDocument(this.text, this.language); + document.setScriptContext(this.scriptContext); + document.setEditorGlobals(this.editorGlobals); + } + + init(width, height); + } + + /** + * Convert ScriptDocument lines to the legacy LineData format. + */ + private void rebuildLineData() { + lines.clear(); + for (ScriptLine scriptLine : document.getLines()) { + LineData ld = new LineData( + scriptLine.getText(), + scriptLine.getGlobalStart(), + scriptLine.getGlobalEnd() + ); + + // Copy indent guides + ld.indentCols.addAll(scriptLine.getIndentGuides()); + + // Convert tokens - use fully qualified name to avoid conflict with inherited Token + for (noppes.npcs.client.gui.util.script.interpreter.token.Token interpreterToken : scriptLine.getTokens()) { + ld.tokens.add(new JavaTextContainer.Token( + interpreterToken.getText(), + toLegacyTokenType(interpreterToken.getType()), + interpreterToken.getGlobalStart(), + interpreterToken.getGlobalEnd() + )); + } + + lines.add(ld); + } + } + + /** + * Main formatting entry point - matches JavaTextContainer signature. + */ + @Override + public void formatCodeText() { + if (!USE_NEW_INTERPRETER) { + super.formatCodeText(); + return; + } + + document.formatCodeText(); + rebuildLineData(); + } + + /** + * Convert new interpreter TokenType to legacy JavaTextContainer.TokenType format. + */ + private JavaTextContainer.TokenType toLegacyTokenType( + noppes.npcs.client.gui.util.script.interpreter.token.TokenType type) { + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.COMMENT) return JavaTextContainer.TokenType.COMMENT; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.STRING) return JavaTextContainer.TokenType.STRING; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.CLASS_KEYWORD) return JavaTextContainer.TokenType.CLASS_KEYWORD; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.IMPORT_KEYWORD) return JavaTextContainer.TokenType.IMPORT_KEYWORD; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.KEYWORD) return JavaTextContainer.TokenType.KEYWORD; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.MODIFIER) return JavaTextContainer.TokenType.MODIFIER; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.INTERFACE_DECL) return JavaTextContainer.TokenType.INTERFACE_DECL; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.ENUM_DECL) return JavaTextContainer.TokenType.ENUM_DECL; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.CLASS_DECL) return JavaTextContainer.TokenType.CLASS_DECL; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.IMPORTED_CLASS) return JavaTextContainer.TokenType.IMPORTED_CLASS; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.TYPE_DECL) return JavaTextContainer.TokenType.TYPE_DECL; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.METHOD_DECL) return JavaTextContainer.TokenType.METHOD_DECARE; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.METHOD_CALL) return JavaTextContainer.TokenType.METHOD_CALL; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.LITERAL) return JavaTextContainer.TokenType.NUMBER; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.GLOBAL_FIELD) return JavaTextContainer.TokenType.GLOBAL_FIELD; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.LOCAL_FIELD) return JavaTextContainer.TokenType.LOCAL_FIELD; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.PARAMETER) return JavaTextContainer.TokenType.PARAMETER; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.UNDEFINED_VAR) return JavaTextContainer.TokenType.UNDEFINED_VAR; + if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.VARIABLE) return JavaTextContainer.TokenType.VARIABLE; + return JavaTextContainer.TokenType.DEFAULT; + } + + // ==================== ACCESSORS ==================== + + /** + * Get the underlying ScriptDocument for advanced operations. + */ + public ScriptDocument getDocument() { + return document; + } + + /** + * Get the interpreter Token at a specific global position in the text. + * Returns null if no token is at that position or if the interpreter is disabled. + * + * @param globalPosition Position in the document text + * @return The Token at that position, or null + */ + public noppes.npcs.client.gui.util.script.interpreter.token.Token getInterpreterTokenAt(int globalPosition) { + if (!USE_NEW_INTERPRETER || document == null) { + return null; + } + + ScriptLine line = document.getLineAt(globalPosition); + if (line == null) { + return null; + } + + return line.getTokenAt(globalPosition); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/bridge/DtsJavaBridge.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/bridge/DtsJavaBridge.java new file mode 100644 index 000000000..cebd1bcfc --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/bridge/DtsJavaBridge.java @@ -0,0 +1,298 @@ +package noppes.npcs.client.gui.util.script.interpreter.bridge; + +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSFieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSMethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class DtsJavaBridge { + + /** Cache of reflection Method -> matching JS method info (for docs/param names). */ + private static final Map METHOD_CACHE = new ConcurrentHashMap<>(); + /** Cache of reflection Field -> matching JS field info (for docs/typing). */ + private static final Map FIELD_CACHE = new ConcurrentHashMap<>(); + /** Cache of reflection Method -> resolved Java TypeInfo override from .d.ts. */ + private static final Map RETURN_OVERRIDE_CACHE = new ConcurrentHashMap<>(); + /** Negative cache for methods with no usable override. */ + private static final Set RETURN_OVERRIDE_MISSES = ConcurrentHashMap.newKeySet(); + + private DtsJavaBridge() {} + + public static void clearCache() { + METHOD_CACHE.clear(); + FIELD_CACHE.clear(); + RETURN_OVERRIDE_CACHE.clear(); + RETURN_OVERRIDE_MISSES.clear(); + } + + /** + * Resolve a Java return type override for a reflected method using its .d.ts twin. + * + * The override is used only for editor typing (e.g., Janino hover/autocomplete) and + * never changes runtime reflection. This returns a Java-resolved TypeInfo when the + * .d.ts return type can be mapped to a concrete Java class. + */ + public static TypeInfo resolveReturnTypeOverride(Method method, TypeInfo containingType, JSMethodInfo jsMethod) { + if (method == null || jsMethod == null) return null; + + TypeInfo cached = RETURN_OVERRIDE_CACHE.get(method); + if (cached != null) return cached; + if (RETURN_OVERRIDE_MISSES.contains(method)) return null; + + String jsReturnType = jsMethod.getReturnType(); + if (jsReturnType == null || jsReturnType.isEmpty()) { + RETURN_OVERRIDE_MISSES.add(method); + return null; + } + + String normalizedJsReturn = jsReturnType; + if (normalizedJsReturn.contains("|")) { + normalizedJsReturn = normalizedJsReturn.split("\\|")[0].trim(); + } + normalizedJsReturn = normalizedJsReturn.replace("?", "").trim(); + + // Fast-path: skip resolver if the .d.ts return matches reflection (common case). + Class reflectedReturn = method.getReturnType(); + if (reflectedReturn != null) { + if (normalizedJsReturn.equals(reflectedReturn.getName()) + || normalizedJsReturn.equals(reflectedReturn.getSimpleName()) + || normalizedJsReturn.equals("Java." + reflectedReturn.getName())) { + RETURN_OVERRIDE_MISSES.add(method); + return null; + } + + String lower = normalizedJsReturn.toLowerCase(); + if (("void".equals(lower) && (reflectedReturn == void.class || reflectedReturn == Void.class)) + || ("boolean".equals(lower) && (reflectedReturn == boolean.class || reflectedReturn == Boolean.class)) + || ("string".equals(lower) && reflectedReturn == String.class) + || ("number".equals(lower) && (reflectedReturn == double.class || reflectedReturn == Double.class))) { + RETURN_OVERRIDE_MISSES.add(method); + return null; + } + } + + TypeResolver resolver = TypeResolver.getInstance(); + TypeInfo resolved = resolver.resolveJSType(normalizedJsReturn); + TypeInfo javaResolved = null; + + if (resolved != null) { + if (resolved.getJavaClass() != null) { + javaResolved = resolved; + } else if (resolved.isJSType() && resolved.getJSTypeInfo() != null) { + String javaFqn = resolved.getJSTypeInfo().getJavaFqn(); + if (javaFqn != null && !javaFqn.isEmpty()) { + javaResolved = resolver.resolveFullName(javaFqn); + } + } + } + + if (javaResolved == null || javaResolved.getJavaClass() == null) { + RETURN_OVERRIDE_MISSES.add(method); + return null; + } + + RETURN_OVERRIDE_CACHE.put(method, javaResolved); + return javaResolved; + } + + public static JSMethodInfo findMatchingMethod(Method method, TypeInfo containingType) { + JSMethodInfo cached = METHOD_CACHE.get(method); + if (cached != null) return cached; + + JSTypeInfo jsType = findJSTypeInfo(method, containingType); + if (jsType == null) return null; + + List overloads = jsType.getMethodOverloads(method.getName()); + if (overloads.isEmpty()) return null; + + Class[] paramTypes = method.getParameterTypes(); + JSMethodInfo best = null; + int bestScore = -1; + + for (JSMethodInfo candidate : overloads) { + if (candidate.getParameterCount() != paramTypes.length) continue; + int score = scoreOverload(candidate, paramTypes, containingType, method.isVarArgs()); + if (score > bestScore) { + bestScore = score; + best = candidate; + } + } + + if (bestScore >= 0 && best != null) { + METHOD_CACHE.put(method, best); + return best; + } + return null; + } + + public static JSFieldInfo findMatchingField(Field field, TypeInfo containingType) { + JSFieldInfo cached = FIELD_CACHE.get(field); + if (cached != null) return cached; + + JSTypeInfo jsType = findJSTypeInfo(field, containingType); + if (jsType == null) return null; + JSFieldInfo match = jsType.getField(field.getName()); + if (match != null) { + FIELD_CACHE.put(field, match); + } + return match; + } + + private static int scoreOverload(JSMethodInfo method, Class[] paramTypes, TypeInfo containingType, boolean isVarArgs) { + int score = 0; + List jsParams = method.getParameters(); + for (int i = 0; i < paramTypes.length; i++) { + int paramScore = scoreParam(paramTypes[i], jsParams.get(i), containingType, isVarArgs, i == paramTypes.length - 1); + if (paramScore < 0) return -1; + score += paramScore; + } + return score; + } + + private static int scoreParam(Class javaParam, JSMethodInfo.JSParameterInfo jsParam, TypeInfo containingType, boolean isVarArgs, boolean isLastParam) { + if (javaParam == null || jsParam == null) return -1; + + TypeInfo resolved = jsParam.getResolvedType(containingType); + if (resolved != null && resolved.getJavaClass() != null) { + Class jsClass = resolved.getJavaClass(); + if (javaParam.equals(jsClass)) return 4; + if (jsClass.isAssignableFrom(javaParam)) return 3; + if (javaParam.isAssignableFrom(jsClass)) return 2; + } + + String jsTypeName = jsParam.getType(); + if (jsTypeName == null || jsTypeName.isEmpty()) return -1; + + if (isVarArgs && isLastParam && javaParam.isArray()) { + if (matchesArrayElement(javaParam.getComponentType(), jsTypeName, containingType)) { + return 2; + } + } + + if (matchesPrimitive(jsTypeName, javaParam)) { + return 3; + } + + if (javaParam.isArray() && jsTypeName.endsWith("[]")) { + String elementType = jsTypeName.substring(0, jsTypeName.length() - 2); + return matchesArrayElement(javaParam.getComponentType(), elementType, containingType) ? 2 : -1; + } + + JSTypeInfo jsType = resolveJSTypeInfo(jsTypeName); + if (jsType != null && jsType.getJavaFqn() != null) { + String javaFqn = normalizeJavaFqn(jsType.getJavaFqn()); + String paramFqn = normalizeJavaFqn(javaParam.getName()); + if (javaFqn.equals(paramFqn)) return 3; + } + + if (jsTypeName.equals(javaParam.getSimpleName())) return 1; + if (jsTypeName.equals(javaParam.getName())) return 1; + if ("any".equals(jsTypeName)) return 1; + return -1; + } + + private static boolean matchesArrayElement(Class elementClass, String jsElementType, TypeInfo containingType) { + if (elementClass == null) return false; + if (matchesPrimitive(jsElementType, elementClass)) return true; + + JSTypeInfo jsType = resolveJSTypeInfo(jsElementType); + if (jsType != null && jsType.getJavaFqn() != null) { + String javaFqn = normalizeJavaFqn(jsType.getJavaFqn()); + String elementFqn = normalizeJavaFqn(elementClass.getName()); + return javaFqn.equals(elementFqn); + } + + return jsElementType.equals(elementClass.getSimpleName()) || jsElementType.equals(elementClass.getName()); + } + + private static boolean matchesPrimitive(String jsTypeName, Class javaParam) { + switch (jsTypeName) { + case "string": + return javaParam == String.class || javaParam == char.class || javaParam == Character.class; + case "boolean": + return javaParam == boolean.class || javaParam == Boolean.class; + case "number": + return isNumberType(javaParam); + case "void": + return javaParam == void.class || javaParam == Void.class; + default: + return false; + } + } + + private static boolean isNumberType(Class type) { + if (type == null) return false; + return type == byte.class || type == short.class || type == int.class || type == long.class + || type == float.class || type == double.class + || Number.class.isAssignableFrom(type); + } + + private static JSTypeInfo findJSTypeInfo(Method method, TypeInfo containingType) { + if (containingType != null && containingType.isJSType()) { + return containingType.getJSTypeInfo(); + } + + Class javaClass = containingType != null ? containingType.getJavaClass() : method.getDeclaringClass(); + return resolveJSTypeInfo(javaClass); + } + + private static JSTypeInfo findJSTypeInfo(Field field, TypeInfo containingType) { + if (containingType != null && containingType.isJSType()) { + return containingType.getJSTypeInfo(); + } + + Class javaClass = containingType != null ? containingType.getJavaClass() : field.getDeclaringClass(); + return resolveJSTypeInfo(javaClass); + } + + private static JSTypeInfo resolveJSTypeInfo(Class javaClass) { + if (javaClass == null) return null; + + JSTypeInfo direct = resolveJSTypeInfo(javaClass.getName()); + if (direct != null) return direct; + + for (Class iface : javaClass.getInterfaces()) { + JSTypeInfo ifaceType = resolveJSTypeInfo(iface.getName()); + if (ifaceType != null) return ifaceType; + } + + Class superClass = javaClass.getSuperclass(); + if (superClass != null && superClass != Object.class) { + return resolveJSTypeInfo(superClass); + } + + return null; + } + + private static JSTypeInfo resolveJSTypeInfo(String javaFqnOrType) { + if (javaFqnOrType == null || javaFqnOrType.isEmpty()) return null; + if (javaFqnOrType.startsWith("Java.")) { + javaFqnOrType = javaFqnOrType.substring(5); + } + JSTypeRegistry registry = TypeResolver.getInstance().getJSTypeRegistry(); + JSTypeInfo direct = registry.getTypeByJavaFqn(javaFqnOrType); + if (direct != null) return direct; + + String normalized = normalizeJavaFqn(javaFqnOrType); + if (!normalized.equals(javaFqnOrType)) { + JSTypeInfo normalizedType = registry.getTypeByJavaFqn(normalized); + if (normalizedType != null) return normalizedType; + } + + return registry.getType(javaFqnOrType); + } + + private static String normalizeJavaFqn(String javaFqn) { + if (javaFqn == null) return null; + return javaFqn.replace('$', '.'); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/CastExpressionResolver.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/CastExpressionResolver.java new file mode 100644 index 000000000..d2b37c64a --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/CastExpressionResolver.java @@ -0,0 +1,223 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import java.util.List; +import java.util.function.Function; + +/** + * Helper class for resolving cast expressions and distinguishing them from parenthesized expressions. + * Handles: + * - Simple casts: (Type) expr + * - Nested casts: ((Type) expr) + * - Cast chains: ((Type) expr).method() + * - Parenthesized expressions: (expr) + * + * @Deprecated ExpressionTypeResolver API has all this built in. + */ +@Deprecated +public class CastExpressionResolver { + + /** + * Resolve a cast or parenthesized expression. + * + * @param expr The expression starting with '(' + * @param position The position in the source text + * @param resolveType Function to resolve type names to TypeInfo + * @param resolveExpression Function to resolve arbitrary expressions + * @param parseChain Function to parse expression chain + * @param resolveChainSegment Function to resolve chain segments + * @return The resolved type + */ + public static TypeInfo resolveCastOrParenthesizedExpression( + String expr, + int position, + Function resolveType, + TypeResolver resolveExpression, + ChainParser parseChain, + ChainSegmentResolver resolveChainSegment) { + + if (!expr.startsWith("(")) return null; + + // Find the matching closing parenthesis for the first open paren + int depth = 0; + int closeParen = -1; + boolean inString = false; + char stringChar = 0; + + for (int i = 0; i < expr.length(); i++) { + char c = expr.charAt(i); + + // Handle string literals + if ((c == '"' || c == '\'') && (i == 0 || expr.charAt(i - 1) != '\\')) { + if (!inString) { + inString = true; + stringChar = c; + } else if (c == stringChar) { + inString = false; + } + continue; + } + if (inString) continue; + + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + closeParen = i; + break; + } + } + } + + if (closeParen < 0) return null; + + // Extract content inside parens + String insideParens = expr.substring(1, closeParen).trim(); + String afterParens = expr.substring(closeParen + 1).trim(); + + // Check if this is a cast expression + // A cast has a type name inside the parens (starts with uppercase or is primitive) + // and is followed by an expression (not an operator or nothing) + if (looksLikeCastType(insideParens) && looksLikeExpressionStart(afterParens)) { + // This is a cast: (Type) rest + TypeInfo castType = resolveType.apply(insideParens); + if (castType != null && castType.isResolved()) { + // If there's more after the cast target, resolve the chain + // E.g., ((EntityPlayer) entity).worldObj + if (!afterParens.isEmpty()) { + // Check if afterParens starts with '.' for a method/field chain + if (afterParens.startsWith(".")) { + // Continue resolving the chain from the cast type + String chainExpr = afterParens.substring(1).trim(); + List segments = parseChain.parse(chainExpr); + TypeInfo currentType = castType; + for (T segment : segments) { + currentType = resolveChainSegment.resolve(currentType, segment); + if (currentType == null) return null; + } + return currentType; + } + } + return castType; + } + } + + // Not a cast - treat as parenthesized expression + // Could be ((expr).method()) or just (expr) + + // First, resolve what's inside the parens + TypeInfo innerType = resolveExpression.resolve(insideParens, position); + + // If there's a chain after the parens, continue resolving + if (innerType != null && !afterParens.isEmpty() && afterParens.startsWith(".")) { + String chainExpr = afterParens.substring(1).trim(); + List segments = parseChain.parse(chainExpr); + TypeInfo currentType = innerType; + for (T segment : segments) { + currentType = resolveChainSegment.resolve(currentType, segment); + if (currentType == null) return null; + } + return currentType; + } + + return innerType; + } + + /** + * Check if a string looks like a valid type name for a cast. + * Valid: EntityPlayer, int, String, java.lang.String, int[] + */ + public static boolean looksLikeCastType(String s) { + if (s == null || s.isEmpty()) return false; + s = s.trim(); + + // Handle array types: Type[] or Type[][] + while (s.endsWith("[]")) { + s = s.substring(0, s.length() - 2).trim(); + } + + if (s.isEmpty()) return false; + + // Primitive types + switch (s) { + case "byte": case "short": case "int": case "long": + case "float": case "double": case "char": case "boolean": + return true; + } + + // Class type: starts with uppercase or is fully qualified + if (Character.isUpperCase(s.charAt(0))) return true; + + // Fully qualified name: contains dots and ends with uppercase segment + if (s.contains(".")) { + String[] parts = s.split("\\."); + String lastPart = parts[parts.length - 1]; + return !lastPart.isEmpty() && Character.isUpperCase(lastPart.charAt(0)); + } + + return false; + } + + /** + * Check if a string looks like the start of an expression (what follows a cast). + * Valid: identifier, (, new, literals, this, etc. + * Invalid: empty, operators like +, -, etc. + */ + public static boolean looksLikeExpressionStart(String s) { + if (s == null || s.isEmpty()) return false; + s = s.trim(); + if (s.isEmpty()) return false; + + char first = s.charAt(0); + + // Parenthesis (nested cast or parenthesized expr) + if (first == '(') return true; + + // Identifier or keyword + if (Character.isJavaIdentifierStart(first)) return true; + + // String or char literal + if (first == '"' || first == '\'') return true; + + // Number literal (could start with digit or dot for .5) + if (Character.isDigit(first)) return true; + if (first == '.' && s.length() > 1 && Character.isDigit(s.charAt(1))) return true; + + // Unary operators followed by expression + if (first == '!' || first == '~') return true; + if ((first == '+' || first == '-') && s.length() > 1) { + // Could be unary +/- or ++/-- + char second = s.charAt(1); + if (second == first || Character.isDigit(second) || Character.isJavaIdentifierStart(second) || second == '(') { + return true; + } + } + + return false; + } + + /** + * Functional interface for resolving type expressions. + */ + @FunctionalInterface + public interface TypeResolver { + TypeInfo resolve(String expr, int position); + } + + /** + * Functional interface for parsing expression chains. + */ + @FunctionalInterface + public interface ChainParser { + List parse(String expr); + } + + /** + * Functional interface for resolving chain segments. + */ + @FunctionalInterface + public interface ChainSegmentResolver { + TypeInfo resolve(TypeInfo currentType, T segment); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionNode.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionNode.java new file mode 100644 index 000000000..f3d588f4d --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionNode.java @@ -0,0 +1,361 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public abstract class ExpressionNode { + protected final int start; + protected final int end; + + protected ExpressionNode(int start, int end) { + this.start = start; + this.end = end; + } + + public int getStart() { return start; } + public int getEnd() { return end; } + public abstract TypeInfo resolveType(TypeResolverContext resolver); + + public static class IntLiteralNode extends ExpressionNode { + private final String value; + public IntLiteralNode(String value, int start, int end) { super(start, end); this.value = value; } + public String getValue() { return value; } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("int"); } + } + + public static class LongLiteralNode extends ExpressionNode { + public LongLiteralNode(String value, int start, int end) { super(start, end); } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("long"); } + } + + public static class FloatLiteralNode extends ExpressionNode { + public FloatLiteralNode(String value, int start, int end) { super(start, end); } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("float"); } + } + + public static class DoubleLiteralNode extends ExpressionNode { + public DoubleLiteralNode(String value, int start, int end) { super(start, end); } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("double"); } + } + + public static class BooleanLiteralNode extends ExpressionNode { + public BooleanLiteralNode(boolean value, int start, int end) { super(start, end); } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("boolean"); } + } + + public static class CharLiteralNode extends ExpressionNode { + public CharLiteralNode(String value, int start, int end) { super(start, end); } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("char"); } + } + + public static class StringLiteralNode extends ExpressionNode { + private final String value; + public StringLiteralNode(String value, int start, int end) { super(start, end); this.value = value; } + public String getValue() { return value; } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromClass(String.class); } + } + + public static class NullLiteralNode extends ExpressionNode { + public NullLiteralNode(int start, int end) { super(start, end); } + public TypeInfo resolveType(TypeResolverContext resolver) { + return TypeInfo.unresolved("null", ""); + } + } + + public static class IdentifierNode extends ExpressionNode { + private final String name; + public IdentifierNode(String name, int start, int end) { super(start, end); this.name = name; } + public String getName() { return name; } + public TypeInfo resolveType(TypeResolverContext resolver) { return resolver.resolveIdentifier(name); } + } + + public static class MemberAccessNode extends ExpressionNode { + private final ExpressionNode target; + private final String memberName; + public MemberAccessNode(ExpressionNode target, String memberName, int start, int end) { + super(start, end); this.target = target; this.memberName = memberName; + } + public ExpressionNode getTarget() { return target; } + public String getMemberName() { return memberName; } + public TypeInfo resolveType(TypeResolverContext resolver) { + TypeInfo targetType = target.resolveType(resolver); + if (targetType == null || !targetType.isResolved()) return null; + return resolver.resolveMemberAccess(targetType, memberName); + } + } + + public static class MethodCallNode extends ExpressionNode { + private final ExpressionNode target; + private final String methodName; + private final List arguments; + public MethodCallNode(ExpressionNode target, String methodName, List arguments, int start, int end) { + super(start, end); this.target = target; this.methodName = methodName; + this.arguments = arguments != null ? new ArrayList<>(arguments) : new ArrayList<>(); + } + public ExpressionNode getTarget() { return target; } + public String getMethodName() { return methodName; } + public List getArguments() { return Collections.unmodifiableList(arguments); } + public TypeInfo resolveType(TypeResolverContext resolver) { + TypeInfo targetType = target != null ? target.resolveType(resolver) : null; + TypeInfo[] argTypes = new TypeInfo[arguments.size()]; + for (int i = 0; i < arguments.size(); i++) argTypes[i] = arguments.get(i).resolveType(resolver); + return resolver.resolveMethodCall(targetType, methodName, argTypes); + } + } + + public static class ArrayAccessNode extends ExpressionNode { + private final ExpressionNode array; + private final ExpressionNode index; + public ArrayAccessNode(ExpressionNode array, ExpressionNode index, int start, int end) { + super(start, end); this.array = array; this.index = index; + } + public ExpressionNode getArray() { return array; } + public ExpressionNode getIndex() { return index; } + public TypeInfo resolveType(TypeResolverContext resolver) { + TypeInfo arrayType = array.resolveType(resolver); + if (arrayType == null || !arrayType.isResolved()) return null; + return resolver.resolveArrayAccess(arrayType); + } + } + + public static class NewNode extends ExpressionNode { + private final String typeName; + private final List arguments; + public NewNode(String typeName, List arguments, int start, int end) { + super(start, end); this.typeName = typeName; + this.arguments = arguments != null ? new ArrayList<>(arguments) : new ArrayList<>(); + } + public String getTypeName() { return typeName; } + public List getArguments() { return Collections.unmodifiableList(arguments); } + public TypeInfo resolveType(TypeResolverContext resolver) { return resolver.resolveTypeName(typeName); } + } + + public static class BinaryOpNode extends ExpressionNode { + private final ExpressionNode left; + private final OperatorType operator; + private final ExpressionNode right; + public BinaryOpNode(ExpressionNode left, OperatorType operator, ExpressionNode right, int start, int end) { + super(start, end); this.left = left; this.operator = operator; this.right = right; + } + public ExpressionNode getLeft() { return left; } + public OperatorType getOperator() { return operator; } + public ExpressionNode getRight() { return right; } + public TypeInfo resolveType(TypeResolverContext resolver) { + TypeInfo leftType = left.resolveType(resolver); + TypeInfo rightType = right.resolveType(resolver); + return TypeRules.resolveBinaryOperatorType(operator, leftType, rightType); + } + } + + public static class UnaryOpNode extends ExpressionNode { + private final OperatorType operator; + private final ExpressionNode operand; + private final boolean prefix; + public UnaryOpNode(OperatorType operator, ExpressionNode operand, boolean prefix, int start, int end) { + super(start, end); this.operator = operator; this.operand = operand; this.prefix = prefix; + } + public OperatorType getOperator() { return operator; } + public ExpressionNode getOperand() { return operand; } + public boolean isPrefix() { return prefix; } + public TypeInfo resolveType(TypeResolverContext resolver) { + TypeInfo operandType = operand.resolveType(resolver); + return TypeRules.resolveUnaryOperatorType(operator, operandType); + } + } + + public static class TernaryNode extends ExpressionNode { + private final ExpressionNode condition; + private final ExpressionNode thenExpr; + private final ExpressionNode elseExpr; + public TernaryNode(ExpressionNode condition, ExpressionNode thenExpr, ExpressionNode elseExpr, int start, int end) { + super(start, end); this.condition = condition; this.thenExpr = thenExpr; this.elseExpr = elseExpr; + } + public ExpressionNode getCondition() { return condition; } + public ExpressionNode getThenExpr() { return thenExpr; } + public ExpressionNode getElseExpr() { return elseExpr; } + public TypeInfo resolveType(TypeResolverContext resolver) { + TypeInfo thenType = thenExpr.resolveType(resolver); + TypeInfo elseType = elseExpr.resolveType(resolver); + return TypeRules.resolveTernaryType(thenType, elseType); + } + } + + public static class InstanceofNode extends ExpressionNode { + private final ExpressionNode expression; + private final String typeName; + public InstanceofNode(ExpressionNode expression, String typeName, int start, int end) { + super(start, end); this.expression = expression; this.typeName = typeName; + } + public ExpressionNode getExpression() { return expression; } + public String getTypeName() { return typeName; } + public TypeInfo resolveType(TypeResolverContext resolver) { return TypeInfo.fromPrimitive("boolean"); } + } + + public static class CastNode extends ExpressionNode { + private final String typeName; + private final ExpressionNode expression; + public CastNode(String typeName, ExpressionNode expression, int start, int end) { + super(start, end); this.typeName = typeName; this.expression = expression; + } + public String getTypeName() { return typeName; } + public ExpressionNode getExpression() { return expression; } + public TypeInfo resolveType(TypeResolverContext resolver) { return resolver.resolveTypeName(typeName); } + } + + public static class AssignmentNode extends ExpressionNode { + private final ExpressionNode target; + private final OperatorType operator; + private final ExpressionNode value; + public AssignmentNode(ExpressionNode target, OperatorType operator, ExpressionNode value, int start, int end) { + super(start, end); this.target = target; this.operator = operator; this.value = value; + } + public ExpressionNode getTarget() { return target; } + public OperatorType getOperator() { return operator; } + public ExpressionNode getValue() { return value; } + public TypeInfo resolveType(TypeResolverContext resolver) { return target.resolveType(resolver); } + } + + public static class ParenthesizedNode extends ExpressionNode { + private final ExpressionNode inner; + public ParenthesizedNode(ExpressionNode inner, int start, int end) { super(start, end); this.inner = inner; } + public ExpressionNode getInner() { return inner; } + public TypeInfo resolveType(TypeResolverContext resolver) { return inner.resolveType(resolver); } + } + + public static class LambdaNode extends ExpressionNode { + private final List parameterNames; + private final ExpressionNode body; + private final boolean isBlock; + private noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope scopeRef; + + public LambdaNode(List parameterNames, ExpressionNode body, boolean isBlock, int start, int end) { + super(start, end); + this.parameterNames = parameterNames != null ? new ArrayList<>(parameterNames) : new ArrayList<>(); + this.body = body; + this.isBlock = isBlock; + this.scopeRef = null; + } + + public List getParameterNames() { return Collections.unmodifiableList(parameterNames); } + public ExpressionNode getBody() { return body; } + public boolean isBlock() { return isBlock; } + public noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope getScopeRef() { return scopeRef; } + public void setScopeRef(noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope scope) { + this.scopeRef = scope; + } + + @Override + public TypeInfo resolveType(TypeResolverContext resolver) { + // Lambda type is determined by the expected functional interface type + // See ExpressionTypeResolver.resolveLambdaType() + return TypeInfo.fromClass(Object.class); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + if (parameterNames.size() == 1 && !isBlock) { + sb.append(parameterNames.get(0)); + } else { + sb.append("("); + sb.append(String.join(", ", parameterNames)); + sb.append(")"); + } + sb.append(" -> "); + if (isBlock) { + sb.append("{ ... }"); + } else { + sb.append(body); + } + return sb.toString(); + } + } + + public static class JSFunctionNode extends ExpressionNode { + private final String name; // Optional name for named function expressions + private final List parameterNames; + private final String bodyText; // Raw body text (for block functions) + private noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope scopeRef; + + public JSFunctionNode(String name, List parameterNames, String bodyText, int start, int end) { + super(start, end); + this.name = name; + this.parameterNames = parameterNames != null ? new ArrayList<>(parameterNames) : new ArrayList<>(); + this.bodyText = bodyText; + this.scopeRef = null; + } + + public String getName() { return name; } + public List getParameterNames() { return Collections.unmodifiableList(parameterNames); } + public String getBodyText() { return bodyText; } + public noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope getScopeRef() { return scopeRef; } + public void setScopeRef(noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope scope) { + this.scopeRef = scope; + } + + @Override + public TypeInfo resolveType(TypeResolverContext resolver) { + // JS function type is determined by the expected functional interface type + // See ExpressionTypeResolver.resolveJSFunctionType() + return TypeInfo.fromClass(Object.class); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("function"); + if (name != null && !name.isEmpty()) { + sb.append(" ").append(name); + } + sb.append("("); + sb.append(String.join(", ", parameterNames)); + sb.append(") { "); + if (bodyText.length() > 30) { + sb.append(bodyText.substring(0, 30)).append("..."); + } else { + sb.append(bodyText); + } + sb.append(" }"); + return sb.toString(); + } + } + + public static class MethodReferenceNode extends ExpressionNode { + private final ExpressionNode target; // Object or class name + private final String methodName; + private final boolean isStatic; // Whether it's Class::method vs obj::method + + public MethodReferenceNode(ExpressionNode target, String methodName, boolean isStatic, int start, int end) { + super(start, end); + this.target = target; + this.methodName = methodName; + this.isStatic = isStatic; + } + + public ExpressionNode getTarget() { return target; } + public String getMethodName() { return methodName; } + public boolean isStatic() { return isStatic; } + + @Override + public TypeInfo resolveType(TypeResolverContext resolver) { + // Method reference type is determined by the expected functional interface type + // See ExpressionTypeResolver.resolveMethodReferenceType() + return TypeInfo.fromClass(Object.class); + } + + @Override + public String toString() { + return target + "::" + methodName; + } + } + + public interface TypeResolverContext { + TypeInfo resolveIdentifier(String name); + TypeInfo resolveMemberAccess(TypeInfo targetType, String memberName); + TypeInfo resolveMethodCall(TypeInfo targetType, String methodName, TypeInfo[] argTypes); + TypeInfo resolveArrayAccess(TypeInfo arrayType); + TypeInfo resolveTypeName(String typeName); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionParser.java new file mode 100644 index 000000000..cc61f1f5a --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionParser.java @@ -0,0 +1,549 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +import java.util.ArrayList; +import java.util.List; + +public class ExpressionParser { + private final List tokens; + private int pos; + + public ExpressionParser(List tokens) { + this.tokens = tokens; + this.pos = 0; + } + + public ExpressionNode parse() { + if (tokens.isEmpty()) return null; + return parseLambdaOrExpression(); + } + + private ExpressionNode parseLambdaOrExpression() { + // Check if this is a lambda: (params) -> or param -> + // Look ahead to see if there's a -> after the next identifier/parens + + int checkpoint = pos; + List paramNames = null; + int lambdaStart = current().getStart(); + + // Try to parse parameter list + if (check(ExpressionToken.TokenKind.IDENTIFIER)) { + // Single param without parens + String paramName = current().getText(); + advance(); + if (check(ExpressionToken.TokenKind.LAMBDA_ARROW)) { + paramNames = new ArrayList<>(); + paramNames.add(paramName); + } else { + // Not a lambda, rewind + pos = checkpoint; + } + } else if (check(ExpressionToken.TokenKind.LEFT_PAREN)) { + // Parenthesized params: (a, b) or () + advance(); // consume '(' + paramNames = new ArrayList<>(); + + if (!check(ExpressionToken.TokenKind.RIGHT_PAREN)) { + // Parse param list + while (true) { + if (!check(ExpressionToken.TokenKind.IDENTIFIER)) { + // Not a valid lambda param list, rewind + pos = checkpoint; + paramNames = null; + break; + } + paramNames.add(current().getText()); + advance(); + + if (check(ExpressionToken.TokenKind.COMMA)) { + advance(); + } else if (check(ExpressionToken.TokenKind.RIGHT_PAREN)) { + break; + } else { + // Invalid syntax, rewind + pos = checkpoint; + paramNames = null; + break; + } + } + } + + if (paramNames != null) { + if (check(ExpressionToken.TokenKind.RIGHT_PAREN)) { + advance(); // consume ')' + if (check(ExpressionToken.TokenKind.LAMBDA_ARROW)) { + // Valid lambda! + } else { + // Not a lambda, rewind + pos = checkpoint; + paramNames = null; + } + } else { + // No closing paren, rewind + pos = checkpoint; + paramNames = null; + } + } + } + + if (paramNames != null) { + // This is a lambda! Parse it + advance(); // consume '->' + + // Parse body + ExpressionNode body; + boolean isBlock = false; + int bodyEnd; + + if (check(ExpressionToken.TokenKind.LEFT_BRACE)) { + // Block lambda: { ... } + isBlock = true; + int braceStart = current().getStart(); + advance(); // consume '{' + // Skip to matching '}' + int depth = 1; + while (depth > 0 && pos < tokens.size() && !check(ExpressionToken.TokenKind.EOF)) { + if (check(ExpressionToken.TokenKind.LEFT_BRACE)) depth++; + else if (check(ExpressionToken.TokenKind.RIGHT_BRACE)) depth--; + if (depth > 0) advance(); + } + bodyEnd = current().getEnd(); + if (check(ExpressionToken.TokenKind.RIGHT_BRACE)) { + advance(); // consume '}' + } + body = new ExpressionNode.StringLiteralNode("", braceStart, bodyEnd); // Placeholder + } else { + // Expression lambda + body = parseExpressionInternal(0); + if (body == null) { + // Failed to parse body, return error recovery + return null; + } + bodyEnd = body.getEnd(); + } + + // Create lambda node (scopeRef will be set during type resolution) + return new ExpressionNode.LambdaNode(paramNames, body, isBlock, lambdaStart, bodyEnd); + } + + // Not a lambda, parse as regular expression + return parseExpressionInternal(0); + } + + private ExpressionToken current() { + if (pos >= tokens.size()) return tokens.get(tokens.size() - 1); + return tokens.get(pos); + } + + private ExpressionToken advance() { + ExpressionToken tok = current(); + if (pos < tokens.size()) pos++; + return tok; + } + + private boolean check(ExpressionToken.TokenKind kind) { return current().getKind() == kind; } + + private boolean match(ExpressionToken.TokenKind kind) { + if (check(kind)) { advance(); return true; } + return false; + } + + private ExpressionNode parseExpression(int minPrecedence) { + // For top-level expressions (minPrecedence = 0), check for lambda first + if (minPrecedence == 0) { + return parseLambdaOrExpression(); + } + + return parseExpressionInternal(minPrecedence); + } + + private ExpressionNode parseExpressionInternal(int minPrecedence) { + ExpressionNode left = parsePrefixExpression(); + if (left == null) return null; + + while (true) { + if (check(ExpressionToken.TokenKind.QUESTION) && minPrecedence <= 2) { + left = parseTernary(left); + continue; + } + + OperatorType op = getCurrentBinaryOperator(); + if (op == null) break; + + int precedence = op.getPrecedence(); + if (precedence < minPrecedence) break; + + advance(); + + int nextMinPrecedence = (op.getAssociativity() == OperatorType.Associativity.LEFT) + ? precedence + 1 : precedence; + + ExpressionNode right = parseExpression(nextMinPrecedence); + if (right == null) break; + + if (op == OperatorType.INSTANCEOF) { + String typeName = extractTypeName(right); + left = new ExpressionNode.InstanceofNode(left, typeName, left.getStart(), right.getEnd()); + } else if (op.isAssignment()) { + left = new ExpressionNode.AssignmentNode(left, op, right, left.getStart(), right.getEnd()); + } else { + left = new ExpressionNode.BinaryOpNode(left, op, right, left.getStart(), right.getEnd()); + } + } + + return parsePostfixExpression(left); + } + + private String extractTypeName(ExpressionNode node) { + if (node instanceof ExpressionNode.IdentifierNode) { + return ((ExpressionNode.IdentifierNode) node).getName(); + } + if (node instanceof ExpressionNode.MemberAccessNode) { + ExpressionNode.MemberAccessNode ma = (ExpressionNode.MemberAccessNode) node; + return extractTypeName(ma.getTarget()) + "." + ma.getMemberName(); + } + return "Object"; + } + + private ExpressionNode parseTernary(ExpressionNode condition) { + int start = condition.getStart(); + advance(); + ExpressionNode thenExpr = parseExpression(0); + if (thenExpr == null) return condition; + if (!match(ExpressionToken.TokenKind.COLON)) return condition; + ExpressionNode elseExpr = parseExpression(2); + if (elseExpr == null) return condition; + return new ExpressionNode.TernaryNode(condition, thenExpr, elseExpr, start, elseExpr.getEnd()); + } + + private OperatorType getCurrentBinaryOperator() { + ExpressionToken tok = current(); + if (tok.getKind() == ExpressionToken.TokenKind.OPERATOR) { + OperatorType op = tok.getOperatorType(); + if (op != null && (op.isBinary() || op.isAssignment())) return op; + } + if (tok.getKind() == ExpressionToken.TokenKind.INSTANCEOF) return OperatorType.INSTANCEOF; + return null; + } + + private ExpressionNode parsePrefixExpression() { + ExpressionToken tok = current(); + int start = tok.getStart(); + + // Check for function expression (JS only) + if (tok.getKind() == ExpressionToken.TokenKind.FUNCTION) { + return parseFunctionExpression(); + } + + if (tok.getKind() == ExpressionToken.TokenKind.OPERATOR) { + OperatorType op = tok.getOperatorType(); + if (op != null && isUnaryOperator(op)) { + advance(); + ExpressionNode operand = parsePrefixExpression(); + if (operand == null) return null; + OperatorType unaryOp = toUnaryOperator(op); + return new ExpressionNode.UnaryOpNode(unaryOp, operand, true, start, operand.getEnd()); + } + } + + if (tok.getKind() == ExpressionToken.TokenKind.LEFT_PAREN) { + return parseCastOrParenthesized(); + } + + return parsePrimaryExpression(); + } + + private ExpressionNode parseFunctionExpression() { + int start = current().getStart(); + advance(); // consume 'function' + + // Optional function name + String functionName = null; + if (check(ExpressionToken.TokenKind.IDENTIFIER)) { + functionName = current().getText(); + advance(); + } + + // Expect '(' + if (!check(ExpressionToken.TokenKind.LEFT_PAREN)) { + return null; // Invalid syntax + } + advance(); // consume '(' + + // Parse parameters + List params = new ArrayList<>(); + while (!check(ExpressionToken.TokenKind.RIGHT_PAREN) && !check(ExpressionToken.TokenKind.EOF)) { + if (!check(ExpressionToken.TokenKind.IDENTIFIER)) { + return null; // Invalid parameter + } + params.add(current().getText()); + advance(); + + if (check(ExpressionToken.TokenKind.COMMA)) { + advance(); + } else if (!check(ExpressionToken.TokenKind.RIGHT_PAREN)) { + return null; // Expected comma or closing paren + } + } + + if (!check(ExpressionToken.TokenKind.RIGHT_PAREN)) { + return null; // Expected closing paren + } + advance(); // consume ')' + + // Expect '{' + if (!check(ExpressionToken.TokenKind.LEFT_BRACE)) { + return null; // Invalid syntax + } + advance(); // consume '{' + + // Collect body tokens until matching '}' + StringBuilder bodyBuilder = new StringBuilder(); + int depth = 1; + while (depth > 0 && !check(ExpressionToken.TokenKind.EOF)) { + ExpressionToken bodyToken = current(); + if (bodyToken.getKind() == ExpressionToken.TokenKind.LEFT_BRACE) { + depth++; + } else if (bodyToken.getKind() == ExpressionToken.TokenKind.RIGHT_BRACE) { + depth--; + if (depth == 0) { + break; + } + } + bodyBuilder.append(bodyToken.getText()).append(" "); + advance(); + } + + String bodyText = bodyBuilder.toString().trim(); + int end = current().getEnd(); + + if (!check(ExpressionToken.TokenKind.RIGHT_BRACE)) { + return null; // Expected closing brace + } + advance(); // consume final '}' + + // Find the corresponding InnerCallableScope from ScriptDocument + // This will be set during type resolution when we have access to ScriptDocument + return new ExpressionNode.JSFunctionNode(functionName, params, bodyText, start, end); + } + + private boolean isUnaryOperator(OperatorType op) { + switch (op) { + case ADD: case SUBTRACT: case LOGICAL_NOT: case BITWISE_NOT: + case PRE_INCREMENT: case PRE_DECREMENT: return true; + default: return false; + } + } + + private OperatorType toUnaryOperator(OperatorType op) { + switch (op) { + case ADD: return OperatorType.UNARY_PLUS; + case SUBTRACT: return OperatorType.UNARY_MINUS; + default: return op; + } + } + + private ExpressionNode parseCastOrParenthesized() { + int start = current().getStart(); + advance(); + + if (check(ExpressionToken.TokenKind.IDENTIFIER)) { + String possibleType = current().getText(); + int savedPos = pos; + advance(); + + while (check(ExpressionToken.TokenKind.DOT)) { + advance(); + if (check(ExpressionToken.TokenKind.IDENTIFIER)) { + possibleType += "." + current().getText(); + advance(); + } + } + + while (check(ExpressionToken.TokenKind.LEFT_BRACKET)) { + advance(); + if (!match(ExpressionToken.TokenKind.RIGHT_BRACKET)) { pos = savedPos; break; } + possibleType += "[]"; + } + + if (check(ExpressionToken.TokenKind.RIGHT_PAREN)) { + advance(); + if (canStartExpression()) { + ExpressionNode expr = parsePrefixExpression(); + if (expr != null) { + return new ExpressionNode.CastNode(possibleType, expr, start, expr.getEnd()); + } + } + } + pos = savedPos; + } + + ExpressionNode inner = parseExpression(0); + if (inner != null && match(ExpressionToken.TokenKind.RIGHT_PAREN)) { + return new ExpressionNode.ParenthesizedNode(inner, start, current().getStart()); + } + return inner; + } + + private boolean canStartExpression() { + ExpressionToken.TokenKind kind = current().getKind(); + switch (kind) { + case IDENTIFIER: case INT_LITERAL: case LONG_LITERAL: case FLOAT_LITERAL: + case DOUBLE_LITERAL: case BOOLEAN_LITERAL: case CHAR_LITERAL: case STRING_LITERAL: + case NULL_LITERAL: case NEW: case LEFT_PAREN: case OPERATOR: return true; + default: return false; + } + } + + private ExpressionNode parsePrimaryExpression() { + ExpressionToken tok = current(); + int start = tok.getStart(); + + switch (tok.getKind()) { + case INT_LITERAL: advance(); return new ExpressionNode.IntLiteralNode(tok.getText(), start, tok.getEnd()); + case LONG_LITERAL: advance(); return new ExpressionNode.LongLiteralNode(tok.getText(), start, tok.getEnd()); + case FLOAT_LITERAL: advance(); return new ExpressionNode.FloatLiteralNode(tok.getText(), start, tok.getEnd()); + case DOUBLE_LITERAL: advance(); return new ExpressionNode.DoubleLiteralNode(tok.getText(), start, tok.getEnd()); + case BOOLEAN_LITERAL: advance(); return new ExpressionNode.BooleanLiteralNode("true".equals(tok.getText()), start, tok.getEnd()); + case CHAR_LITERAL: advance(); return new ExpressionNode.CharLiteralNode(tok.getText(), start, tok.getEnd()); + case STRING_LITERAL: advance(); return new ExpressionNode.StringLiteralNode(tok.getText(), start, tok.getEnd()); + case NULL_LITERAL: advance(); return new ExpressionNode.NullLiteralNode(start, tok.getEnd()); + case NEW: return parseNewExpression(); + case IDENTIFIER: return parseIdentifierOrMethodCall(); + case LEFT_PAREN: return parseCastOrParenthesized(); + default: return null; + } + } + + private ExpressionNode parseNewExpression() { + int start = current().getStart(); + advance(); + if (!check(ExpressionToken.TokenKind.IDENTIFIER)) return null; + + StringBuilder typeName = new StringBuilder(current().getText()); + advance(); + + while (check(ExpressionToken.TokenKind.DOT)) { + advance(); + if (check(ExpressionToken.TokenKind.IDENTIFIER)) { + typeName.append(".").append(current().getText()); + advance(); + } + } + + List args = new ArrayList<>(); + if (match(ExpressionToken.TokenKind.LEFT_PAREN)) { + args = parseArgumentList(); + match(ExpressionToken.TokenKind.RIGHT_PAREN); + } + + return new ExpressionNode.NewNode(typeName.toString(), args, start, current().getStart()); + } + + private ExpressionNode parseIdentifierOrMethodCall() { + int start = current().getStart(); + String name = current().getText(); + advance(); + + ExpressionNode result = new ExpressionNode.IdentifierNode(name, start, current().getStart()); + return parseAccessChain(result); + } + + private ExpressionNode parseAccessChain(ExpressionNode base) { + while (true) { + if (check(ExpressionToken.TokenKind.METHOD_REFERENCE)) { + advance(); // consume '::' + + if (!check(ExpressionToken.TokenKind.IDENTIFIER)) { + // Invalid syntax, return what we have + break; + } + + String methodName = current().getText(); + int end = current().getEnd(); + advance(); + + // isStatic is determined during type resolution, not parsing + // We pass false as a placeholder - ExpressionTypeResolver will compute + // the real value based on whether target resolves to a ClassTypeInfo + base = new ExpressionNode.MethodReferenceNode(base, methodName, false, base.getStart(), end); + // Method references don't chain further (can't do obj::method.something) + break; + } else if (check(ExpressionToken.TokenKind.LEFT_PAREN)) { + advance(); + List args = parseArgumentList(); + int end = current().getStart(); + match(ExpressionToken.TokenKind.RIGHT_PAREN); + + String methodName; + ExpressionNode target; + + if (base instanceof ExpressionNode.IdentifierNode) { + methodName = ((ExpressionNode.IdentifierNode) base).getName(); + target = null; + } else if (base instanceof ExpressionNode.MemberAccessNode) { + ExpressionNode.MemberAccessNode ma = (ExpressionNode.MemberAccessNode) base; + methodName = ma.getMemberName(); + target = ma.getTarget(); + } else { + methodName = "apply"; + target = base; + } + + base = new ExpressionNode.MethodCallNode(target, methodName, args, base.getStart(), end); + } else if (check(ExpressionToken.TokenKind.DOT)) { + advance(); + if (!check(ExpressionToken.TokenKind.IDENTIFIER)) break; + String memberName = current().getText(); + int end = current().getEnd(); + advance(); + base = new ExpressionNode.MemberAccessNode(base, memberName, base.getStart(), end); + } else if (check(ExpressionToken.TokenKind.LEFT_BRACKET)) { + advance(); + ExpressionNode index = parseExpression(0); + int end = current().getStart(); + match(ExpressionToken.TokenKind.RIGHT_BRACKET); + if (index != null) { + base = new ExpressionNode.ArrayAccessNode(base, index, base.getStart(), end); + } + } else { + break; + } + } + return base; + } + + private List parseArgumentList() { + List args = new ArrayList<>(); + if (check(ExpressionToken.TokenKind.RIGHT_PAREN)) return args; + + ExpressionNode first = parseExpression(0); + if (first != null) args.add(first); + + while (match(ExpressionToken.TokenKind.COMMA)) { + ExpressionNode arg = parseExpression(0); + if (arg != null) args.add(arg); + } + return args; + } + + private ExpressionNode parsePostfixExpression(ExpressionNode expr) { + if (expr == null) return null; + + while (true) { + ExpressionToken tok = current(); + if (tok.getKind() != ExpressionToken.TokenKind.OPERATOR) break; + + OperatorType op = tok.getOperatorType(); + if (op == OperatorType.PRE_INCREMENT) { + advance(); + expr = new ExpressionNode.UnaryOpNode(OperatorType.POST_INCREMENT, expr, false, expr.getStart(), tok.getEnd()); + } else if (op == OperatorType.PRE_DECREMENT) { + advance(); + expr = new ExpressionNode.UnaryOpNode(OperatorType.POST_DECREMENT, expr, false, expr.getStart(), tok.getEnd()); + } else { + break; + } + } + return expr; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionToken.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionToken.java new file mode 100644 index 000000000..37e2c4ae1 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionToken.java @@ -0,0 +1,65 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +public class ExpressionToken { + + public enum TokenKind { + INT_LITERAL, LONG_LITERAL, FLOAT_LITERAL, DOUBLE_LITERAL, + BOOLEAN_LITERAL, CHAR_LITERAL, STRING_LITERAL, NULL_LITERAL, + IDENTIFIER, NEW, INSTANCEOF, FUNCTION, + OPERATOR, + LEFT_PAREN, RIGHT_PAREN, LEFT_BRACKET, RIGHT_BRACKET, + LEFT_BRACE, RIGHT_BRACE, + DOT, COMMA, QUESTION, COLON, SEMICOLON, + LAMBDA_ARROW, METHOD_REFERENCE, + EOF + } + + private final TokenKind kind; + private final String text; + private final int start; + private final int end; + private final OperatorType operatorType; + + public ExpressionToken(TokenKind kind, String text, int start, int end) { + this(kind, text, start, end, null); + } + + public ExpressionToken(TokenKind kind, String text, int start, int end, OperatorType operatorType) { + this.kind = kind; + this.text = text; + this.start = start; + this.end = end; + this.operatorType = operatorType; + } + + public TokenKind getKind() { return kind; } + public String getText() { return text; } + public int getStart() { return start; } + public int getEnd() { return end; } + public OperatorType getOperatorType() { return operatorType; } + + public static ExpressionToken operator(String symbol, int start, int end) { + OperatorType op = OperatorType.fromBinarySymbol(symbol); + if (op == null) op = OperatorType.fromSymbol(symbol); + return new ExpressionToken(TokenKind.OPERATOR, symbol, start, end, op); + } + + public static ExpressionToken identifier(String name, int start, int end) { + if ("true".equals(name) || "false".equals(name)) { + return new ExpressionToken(TokenKind.BOOLEAN_LITERAL, name, start, end); + } + if ("null".equals(name)) { + return new ExpressionToken(TokenKind.NULL_LITERAL, name, start, end); + } + if ("new".equals(name)) { + return new ExpressionToken(TokenKind.NEW, name, start, end); + } + if ("instanceof".equals(name)) { + return new ExpressionToken(TokenKind.INSTANCEOF, name, start, end); + } + if ("function".equals(name)) { + return new ExpressionToken(TokenKind.FUNCTION, name, start, end); + } + return new ExpressionToken(TokenKind.IDENTIFIER, name, start, end); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionTokenizer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionTokenizer.java new file mode 100644 index 000000000..795344856 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionTokenizer.java @@ -0,0 +1,169 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +import java.util.ArrayList; +import java.util.List; + +public class ExpressionTokenizer { + + public static List tokenize(String expr) { + List tokens = new ArrayList<>(); + int pos = 0; + int len = expr.length(); + + while (pos < len) { + char c = expr.charAt(pos); + + if (Character.isWhitespace(c)) { pos++; continue; } + + if (c == '"') { + int start = pos++; + while (pos < len && expr.charAt(pos) != '"') { + if (expr.charAt(pos) == '\\' && pos + 1 < len) pos++; + pos++; + } + if (pos < len) pos++; + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.STRING_LITERAL, expr.substring(start, pos), start, pos)); + continue; + } + + if (c == '\'') { + int start = pos++; + while (pos < len && expr.charAt(pos) != '\'') { + if (expr.charAt(pos) == '\\' && pos + 1 < len) pos++; + pos++; + } + if (pos < len) pos++; + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.CHAR_LITERAL, expr.substring(start, pos), start, pos)); + continue; + } + + if (Character.isDigit(c) || (c == '.' && pos + 1 < len && Character.isDigit(expr.charAt(pos + 1)))) { + int start = pos; + if (c == '0' && pos + 1 < len) { + char next = expr.charAt(pos + 1); + if (next == 'x' || next == 'X') { + pos += 2; + while (pos < len && isHexDigit(expr.charAt(pos))) pos++; + if (pos < len && (expr.charAt(pos) == 'L' || expr.charAt(pos) == 'l')) { + pos++; + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.LONG_LITERAL, expr.substring(start, pos), start, pos)); + } else { + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.INT_LITERAL, expr.substring(start, pos), start, pos)); + } + continue; + } + if (next == 'b' || next == 'B') { + pos += 2; + while (pos < len && (expr.charAt(pos) == '0' || expr.charAt(pos) == '1')) pos++; + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.INT_LITERAL, expr.substring(start, pos), start, pos)); + continue; + } + } + + while (pos < len && Character.isDigit(expr.charAt(pos))) pos++; + boolean hasDecimal = false; + if (pos < len && expr.charAt(pos) == '.') { + if (pos + 1 < len && Character.isDigit(expr.charAt(pos + 1))) { + hasDecimal = true; + pos++; + while (pos < len && Character.isDigit(expr.charAt(pos))) pos++; + } + } + if (pos < len && (expr.charAt(pos) == 'e' || expr.charAt(pos) == 'E')) { + pos++; + if (pos < len && (expr.charAt(pos) == '+' || expr.charAt(pos) == '-')) pos++; + while (pos < len && Character.isDigit(expr.charAt(pos))) pos++; + hasDecimal = true; + } + + ExpressionToken.TokenKind kind = ExpressionToken.TokenKind.INT_LITERAL; + if (pos < len) { + char suffix = expr.charAt(pos); + if (suffix == 'f' || suffix == 'F') { kind = ExpressionToken.TokenKind.FLOAT_LITERAL; pos++; } + else if (suffix == 'd' || suffix == 'D') { kind = ExpressionToken.TokenKind.DOUBLE_LITERAL; pos++; } + else if (suffix == 'L' || suffix == 'l') { kind = ExpressionToken.TokenKind.LONG_LITERAL; pos++; } + else if (hasDecimal) kind = ExpressionToken.TokenKind.DOUBLE_LITERAL; + } else if (hasDecimal) kind = ExpressionToken.TokenKind.DOUBLE_LITERAL; + + tokens.add(new ExpressionToken(kind, expr.substring(start, pos), start, pos)); + continue; + } + + if (Character.isJavaIdentifierStart(c)) { + int start = pos; + while (pos < len && Character.isJavaIdentifierPart(expr.charAt(pos))) pos++; + tokens.add(ExpressionToken.identifier(expr.substring(start, pos), start, pos)); + continue; + } + + int start = pos; + String op = null; + + // Check for method reference :: (before lambda arrow and single colon) + if (pos + 1 < len && c == ':' && expr.charAt(pos + 1) == ':') { + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.METHOD_REFERENCE, "::", pos, pos + 2)); + pos += 2; + continue; + } + + // Check for lambda arrow + if (pos + 1 < len && c == '-' && expr.charAt(pos + 1) == '>') { + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.LAMBDA_ARROW, "->", pos, pos + 2)); + pos += 2; + continue; + } + + if (pos + 3 <= len) { + String s3 = expr.substring(pos, pos + 3); + if (">>>".equals(s3) || "<<=".equals(s3) || ">>=".equals(s3)) op = s3; + } + if (op == null && pos + 4 <= len) { + String s4 = expr.substring(pos, pos + 4); + if (">>>=".equals(s4)) op = s4; + } + if (op == null && pos + 2 <= len) { + String s2 = expr.substring(pos, pos + 2); + if ("++".equals(s2) || "--".equals(s2) || "+=".equals(s2) || "-=".equals(s2) || + "*=".equals(s2) || "/=".equals(s2) || "%=".equals(s2) || "&=".equals(s2) || + "|=".equals(s2) || "^=".equals(s2) || "==".equals(s2) || "!=".equals(s2) || + "<=".equals(s2) || ">=".equals(s2) || "&&".equals(s2) || "||".equals(s2) || + "<<".equals(s2) || ">>".equals(s2)) op = s2; + } + if (op == null) { + switch (c) { + case '+': case '-': case '*': case '/': case '%': case '&': case '|': + case '^': case '~': case '!': case '<': case '>': case '=': + op = String.valueOf(c); break; + } + } + + if (op != null) { + pos += op.length(); + tokens.add(ExpressionToken.operator(op, start, pos)); + continue; + } + + switch (c) { + case '(': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.LEFT_PAREN, "(", pos, pos + 1)); break; + case ')': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.RIGHT_PAREN, ")", pos, pos + 1)); break; + case '[': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.LEFT_BRACKET, "[", pos, pos + 1)); break; + case ']': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.RIGHT_BRACKET, "]", pos, pos + 1)); break; + case '{': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.LEFT_BRACE, "{", pos, pos + 1)); break; + case '}': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.RIGHT_BRACE, "}", pos, pos + 1)); break; + case '.': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.DOT, ".", pos, pos + 1)); break; + case ',': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.COMMA, ",", pos, pos + 1)); break; + case '?': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.QUESTION, "?", pos, pos + 1)); break; + case ':': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.COLON, ":", pos, pos + 1)); break; + case ';': tokens.add(new ExpressionToken(ExpressionToken.TokenKind.SEMICOLON, ";", pos, pos + 1)); break; + } + pos++; + } + + tokens.add(new ExpressionToken(ExpressionToken.TokenKind.EOF, "", len, len)); + return tokens; + } + + private static boolean isHexDigit(char c) { + return Character.isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionTypeResolver.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionTypeResolver.java new file mode 100644 index 000000000..b3944e9e7 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/ExpressionTypeResolver.java @@ -0,0 +1,848 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.InnerCallableScope; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.ClassTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.OverloadSelector; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.ArrayList; +import java.util.List; + +public class ExpressionTypeResolver { + private final ExpressionNode.TypeResolverContext context; + private final ScriptDocument document; // Access to document for inner scope lookups + private final int basePosition; // Document offset for this expression + + /** + * Static field to track the expected/desired type for the current expression being resolved. + * This allows TypeRules to validate type compatibility in context (e.g., ternary operator branches + * must be compatible with the assignment target type). + * Should be set before calling resolve() and cleared after. + */ + public static TypeInfo CURRENT_EXPECTED_TYPE = null; + + public ExpressionTypeResolver(ExpressionNode.TypeResolverContext context) { + this.context = context; + this.document = null; + this.basePosition = 0; + } + + public ExpressionTypeResolver(ExpressionNode.TypeResolverContext context, ScriptDocument document) { + this(context, document, 0); + } + + public ExpressionTypeResolver(ExpressionNode.TypeResolverContext context, ScriptDocument document, int basePosition) { + this.context = context; + this.document = document; + this.basePosition = basePosition; + } + + public TypeInfo resolve(String expression) { + if (expression == null || expression.trim().isEmpty()) return null; + + try { + List tokens = ExpressionTokenizer.tokenize(expression); + if (tokens.isEmpty()) return null; + + ExpressionParser parser = new ExpressionParser(tokens); + ExpressionNode ast = parser.parse(); + if (ast == null) return null; + + return resolveNodeType(ast); + } catch (Exception e) { + return null; + } + } + + public static boolean containsOperators(String expression) { + if (expression == null) return false; + for (int i = 0; i < expression.length(); i++) { + char c = expression.charAt(i); + switch (c) { + case '+': case '-': case '*': case '/': case '%': + case '&': case '|': case '^': case '~': + case '<': case '>': case '=': case '!': + case '?': case ':': return true; + } + } + return false; + } + + private TypeInfo resolveNodeType(ExpressionNode node) { + if (node == null) return null; + + if (node instanceof ExpressionNode.IntLiteralNode) return TypeInfo.fromPrimitive("int"); + if (node instanceof ExpressionNode.LongLiteralNode) return TypeInfo.fromPrimitive("long"); + if (node instanceof ExpressionNode.FloatLiteralNode) return TypeInfo.fromPrimitive("float"); + if (node instanceof ExpressionNode.DoubleLiteralNode) return TypeInfo.fromPrimitive("double"); + if (node instanceof ExpressionNode.BooleanLiteralNode) return TypeInfo.fromPrimitive("boolean"); + if (node instanceof ExpressionNode.CharLiteralNode) return TypeInfo.fromPrimitive("char"); + if (node instanceof ExpressionNode.StringLiteralNode) return TypeInfo.fromClass(String.class); + if (node instanceof ExpressionNode.NullLiteralNode) return TypeInfo.unresolved("null", ""); + + if (node instanceof ExpressionNode.IdentifierNode) { + return context.resolveIdentifier(((ExpressionNode.IdentifierNode) node).getName()); + } + + if (node instanceof ExpressionNode.MemberAccessNode) { + ExpressionNode.MemberAccessNode ma = (ExpressionNode.MemberAccessNode) node; + TypeInfo targetType = resolveNodeType(ma.getTarget()); + if (targetType == null || !targetType.isResolved()) return null; + return context.resolveMemberAccess(targetType, ma.getMemberName()); + } + + if (node instanceof ExpressionNode.MethodCallNode) { + ExpressionNode.MethodCallNode mc = (ExpressionNode.MethodCallNode) node; + TypeInfo targetType = mc.getTarget() == null ? context.resolveIdentifier("this") : resolveNodeType(mc.getTarget()); + if (targetType == null || !targetType.isResolved()) return null; + TypeInfo[] argTypes = new TypeInfo[mc.getArguments().size()]; + for (int i = 0; i < argTypes.length; i++) argTypes[i] = resolveNodeType(mc.getArguments().get(i)); + return context.resolveMethodCall(targetType, mc.getMethodName(), argTypes); + } + + if (node instanceof ExpressionNode.ArrayAccessNode) { + ExpressionNode.ArrayAccessNode aa = (ExpressionNode.ArrayAccessNode) node; + TypeInfo arrayType = resolveNodeType(aa.getArray()); + if (arrayType == null || !arrayType.isResolved()) return null; + String typeName = arrayType.getFullName(); + if (typeName.endsWith("[]")) { + String elementTypeName = typeName.substring(0, typeName.length() - 2); + return context.resolveTypeName(elementTypeName); + } + return context.resolveArrayAccess(arrayType); + } + + if (node instanceof ExpressionNode.NewNode) { + return context.resolveTypeName(((ExpressionNode.NewNode) node).getTypeName()); + } + + if (node instanceof ExpressionNode.BinaryOpNode) { + ExpressionNode.BinaryOpNode bin = (ExpressionNode.BinaryOpNode) node; + TypeInfo leftType = resolveNodeType(bin.getLeft()); + TypeInfo rightType = resolveNodeType(bin.getRight()); + return TypeRules.resolveBinaryOperatorType(bin.getOperator(), leftType, rightType); + } + + if (node instanceof ExpressionNode.UnaryOpNode) { + ExpressionNode.UnaryOpNode un = (ExpressionNode.UnaryOpNode) node; + TypeInfo operandType = resolveNodeType(un.getOperand()); + return TypeRules.resolveUnaryOperatorType(un.getOperator(), operandType); + } + + if (node instanceof ExpressionNode.TernaryNode) { + ExpressionNode.TernaryNode tern = (ExpressionNode.TernaryNode) node; + TypeInfo thenType = resolveNodeType(tern.getThenExpr()); + TypeInfo elseType = resolveNodeType(tern.getElseExpr()); + return TypeRules.resolveTernaryType(thenType, elseType); + } + + if (node instanceof ExpressionNode.CastNode) { + return context.resolveTypeName(((ExpressionNode.CastNode) node).getTypeName()); + } + + if (node instanceof ExpressionNode.InstanceofNode) { + return TypeInfo.fromPrimitive("boolean"); + } + + if (node instanceof ExpressionNode.AssignmentNode) { + return resolveNodeType(((ExpressionNode.AssignmentNode) node).getTarget()); + } + + if (node instanceof ExpressionNode.ParenthesizedNode) { + return resolveNodeType(((ExpressionNode.ParenthesizedNode) node).getInner()); + } + + if (node instanceof ExpressionNode.LambdaNode) { + return resolveLambdaType((ExpressionNode.LambdaNode) node); + } + + if (node instanceof ExpressionNode.JSFunctionNode) { + return resolveJSFunctionType((ExpressionNode.JSFunctionNode) node); + } + + if (node instanceof ExpressionNode.MethodReferenceNode) { + return resolveMethodReferenceType((ExpressionNode.MethodReferenceNode) node); + } + + return null; + } + + private TypeInfo resolveLambdaType(ExpressionNode.LambdaNode lambda) { + // Lambda type is determined by the expected functional interface + TypeInfo expectedType = CURRENT_EXPECTED_TYPE; + + if (expectedType == null) { + // No expected type context + // Return a generic Object type + return TypeInfo.fromClass(Object.class); + } + + // Get SAM method from the expected functional interface + MethodInfo sam = expectedType.getSingleAbstractMethod(); + if (sam != null && document != null) { + // Find the InnerCallableScope for this lambda by matching position and parameters + InnerCallableScope scope = findLambdaScopeByPosition(lambda.getStart(), lambda.getParameterNames()); + + if (scope != null) { + scope.setExpectedType(expectedType); + + // Extract and apply parameter types from SAM to lambda parameters + List samParams = sam.getParameters(); + List lambdaParams = scope.getParameters(); + + if (samParams.size() == lambdaParams.size()) { + for (int i = 0; i < lambdaParams.size(); i++) { + FieldInfo lambdaParam = lambdaParams.get(i); + TypeInfo inferredType = samParams.get(i).getTypeInfo(); + + // Set inferred type on the parameter + if (inferredType != null) { + lambdaParam.setInferredType(inferredType); + } + } + } + } + + // Type check lambda body (if expression lambda) + if (!lambda.isBlock() && lambda.getBody() != null) { + // Resolve body type for validation + TypeInfo bodyType = resolveNodeType(lambda.getBody()); + // The actual compatibility check will be done during marking phase + } + + return expectedType; + } + + // Fallback: check if it's a recognized functional interface by name + String typeName = expectedType.getFullName(); + if (typeName != null && (typeName.contains("Function") || typeName.contains("Consumer") || + typeName.contains("Predicate") || typeName.contains("Supplier") || + typeName.equals("java.lang.Runnable"))) { + // Update the scope reference if available + if (lambda.getScopeRef() != null) { + lambda.getScopeRef().setExpectedType(expectedType); + } + + return expectedType; + } + + // Not a recognized functional interface, return Object + return TypeInfo.fromClass(Object.class); + } + + /** + * Find the InnerCallableScope for a lambda by matching position and parameter names. + * + * @param expressionRelativePos The start position of the lambda (relative to expression string) + * @param paramNames The parameter names of the lambda + * @return The matching InnerCallableScope, or null if not found + */ + private InnerCallableScope findLambdaScopeByPosition(int expressionRelativePos, List paramNames) { + if (document == null) { + return null; + } + + // Convert expression-relative position to absolute document position + int absolutePos = basePosition + expressionRelativePos; + + for (InnerCallableScope scope : document.getInnerScopes()) { + if (scope.getKind() == InnerCallableScope.Kind.JAVA_LAMBDA) { + // Check if absolute position is in range + if (absolutePos >= scope.getHeaderStart() && absolutePos < scope.getFullEnd()) { + // Verify parameter names match + if (scope.getParameters().size() == paramNames.size()) { + boolean match = true; + for (int i = 0; i < paramNames.size(); i++) { + if (!scope.getParameters().get(i).getName().equals(paramNames.get(i))) { + match = false; + break; + } + } + if (match) { + return scope; + } + } + } + } + } + return null; + } + + private TypeInfo resolveJSFunctionType(ExpressionNode.JSFunctionNode functionNode) { + // JS function type is determined by the expected functional interface (if any) + TypeInfo expectedType = CURRENT_EXPECTED_TYPE; + + if (expectedType == null) { + // No expected type - return generic Function type or Object + // Try to resolve "Function" as a type from the context + TypeInfo functionType = context.resolveTypeName("Function"); + if (functionType != null && functionType.isResolved()) { + return functionType; + } + return TypeInfo.fromClass(Object.class); // Fallback + } + + // Get SAM method from the expected functional interface + MethodInfo sam = expectedType.getSingleAbstractMethod(); + if (sam != null && document != null) { + // Find the InnerCallableScope for this JS function by matching position and parameters + InnerCallableScope scope = findJSFunctionScopeByPosition(functionNode.getStart(), functionNode.getParameterNames()); + + if (scope != null) { + scope.setExpectedType(expectedType); + + // Extract and apply parameter types from SAM to function parameters + List samParams = sam.getParameters(); + List functionParams = scope.getParameters(); + + if (samParams.size() == functionParams.size()) { + for (int i = 0; i < functionParams.size(); i++) { + FieldInfo functionParam = functionParams.get(i); + TypeInfo inferredType = samParams.get(i).getTypeInfo(); + + // Set inferred type on the parameter + if (inferredType != null) { + functionParam.setInferredType(inferredType); + } + } + } + } + + return expectedType; + } + + // Fallback: check if expected type is compatible with a function + String typeName = expectedType.getFullName(); + if (typeName != null && (typeName.contains("Function") || typeName.contains("Consumer") || + typeName.contains("Predicate") || typeName.contains("Supplier") || + typeName.equals("java.lang.Runnable"))) { + // This is likely a functional interface + + // Update the InnerCallableScope with the expected type + if (functionNode.getScopeRef() != null) { + functionNode.getScopeRef().setExpectedType(expectedType); + } + + return expectedType; + } + + // Expected type is not a recognized functional interface + // Check if it's a generic JS "Function" type expectation + if (typeName != null && typeName.equals("Function")) { + return expectedType; + } + + return TypeInfo.fromClass(Object.class); // Fallback + } + + /** + * Find the InnerCallableScope for a JS function by matching position and parameter names. + * + * @param expressionRelativePos The start position of the function (relative to expression string) + * @param paramNames The parameter names of the function + * @return The matching InnerCallableScope, or null if not found + */ + private InnerCallableScope findJSFunctionScopeByPosition(int expressionRelativePos, List paramNames) { + if (document == null) { + return null; + } + + // Convert expression-relative position to absolute document position + int absolutePos = basePosition + expressionRelativePos; + + for (InnerCallableScope scope : document.getInnerScopes()) { + if (scope.getKind() == InnerCallableScope.Kind.JS_FUNCTION_EXPR) { + // Check if absolute position is in range + if (absolutePos >= scope.getHeaderStart() && absolutePos < scope.getFullEnd()) { + // Verify parameter names match + if (scope.getParameters().size() == paramNames.size()) { + boolean match = true; + for (int i = 0; i < paramNames.size(); i++) { + if (!scope.getParameters().get(i).getName().equals(paramNames.get(i))) { + match = false; + break; + } + } + if (match) { + return scope; + } + } + } + } + } + return null; + } + + // ==================== Method Reference Resolution ==================== + + /** + * Resolve the type of a method reference expression (target::methodName). + * + * Supports all Java method reference forms: + * - this::method - Instance method on current object + * - super::method - Instance method on superclass + * - variable::method - Instance method on a variable's value + * - ClassName::method - Static method OR unbound instance method + * - pkg.ClassName::method - Fully qualified class reference + * - ClassName::new - Constructor reference + * - Type[]::new - Array constructor reference + * + * @param methodRef The method reference AST node + * @return The resolved type (functional interface type if valid, Object if invalid) + */ + private TypeInfo resolveMethodReferenceType(ExpressionNode.MethodReferenceNode methodRef) { + TypeInfo expectedType = CURRENT_EXPECTED_TYPE; + + // Must have expected functional interface context + if (expectedType == null || !expectedType.isFunctionalInterface()) { + return TypeInfo.fromClass(Object.class); + } + + MethodInfo sam = expectedType.getSingleAbstractMethod(); + if (sam == null) { + return TypeInfo.fromClass(Object.class); + } + + // Resolve target and determine if it's a class reference (static context) + MethodReferenceTarget resolvedTarget = resolveMethodReferenceTarget(methodRef); + if (resolvedTarget == null || resolvedTarget.type == null) { + // Unresolved target - return expected type to allow forward references + return expectedType; + } + + String methodName = methodRef.getMethodName(); + + // Handle constructor references (ClassName::new or Type[]::new) + if ("new".equals(methodName)) { + return resolveConstructorReference(resolvedTarget, sam, expectedType); + } + + // Handle method references + return resolveMethodReference(resolvedTarget, methodName, sam, expectedType); + } + + /** + * Encapsulates the resolved target of a method reference. + */ + private static class MethodReferenceTarget { + final TypeInfo type; // The resolved type + final boolean isClassRef; // True if target is a class reference (for static access) + + MethodReferenceTarget(TypeInfo type, boolean isClassRef) { + this.type = type; + this.isClassRef = isClassRef; + } + } + + /** + * Resolve the target (left side of ::) for a method reference. + * Returns both the type and whether it's a class reference. + */ + private MethodReferenceTarget resolveMethodReferenceTarget(ExpressionNode.MethodReferenceNode methodRef) { + ExpressionNode target = methodRef.getTarget(); + if (target == null) { + return null; + } + + // Handle simple identifiers: this, super, variable, ClassName + if (target instanceof ExpressionNode.IdentifierNode) { + String name = ((ExpressionNode.IdentifierNode) target).getName(); + return resolveSimpleTarget(name); + } + + // Handle qualified names: pkg.ClassName or outer.Inner + if (target instanceof ExpressionNode.MemberAccessNode) { + return resolveQualifiedTarget((ExpressionNode.MemberAccessNode) target); + } + + // Handle array type targets: Type[] + if (target instanceof ExpressionNode.ArrayAccessNode) { + TypeInfo arrayType = resolveNodeType(target); + if (arrayType != null && arrayType.isResolved()) { + // Array type is always a class reference (for Type[]::new) + return new MethodReferenceTarget(arrayType, true); + } + } + + // Fallback: resolve as expression (e.g., (expr)::method) + TypeInfo exprType = resolveNodeType(target); + if (exprType != null && exprType.isResolved()) { + // Expression results are always instance references + return new MethodReferenceTarget(exprType, false); + } + + return null; + } + + /** + * Resolve a simple identifier as method reference target. + */ + private MethodReferenceTarget resolveSimpleTarget(String name) { + // this::method - instance method on current object + if ("this".equals(name)) { + TypeInfo thisType = context.resolveIdentifier("this"); + return thisType != null ? new MethodReferenceTarget(thisType, false) : null; + } + + // super::method - instance method on superclass + if ("super".equals(name)) { + TypeInfo superType = context.resolveIdentifier("super"); + return superType != null ? new MethodReferenceTarget(superType, false) : null; + } + + // Try as type name first (ClassName::method) + TypeInfo typeInfo = context.resolveTypeName(name); + if (typeInfo != null && typeInfo.isResolved()) { + // Wrap as ClassTypeInfo to indicate class reference + TypeInfo classRef = new ClassTypeInfo(typeInfo); + return new MethodReferenceTarget(classRef, true); + } + + // Try as variable (instance::method) + TypeInfo varType = context.resolveIdentifier(name); + if (varType != null && varType.isResolved()) { + // Check if variable holds a Class reference (e.g., var File = Java.type("java.io.File")) + boolean isClassRef = varType.isClassReference(); + return new MethodReferenceTarget(varType, isClassRef); + } + + return null; + } + + /** + * Resolve a qualified name (a.b.c) as method reference target. + * Handles both package-qualified class names and nested class access. + */ + private MethodReferenceTarget resolveQualifiedTarget(ExpressionNode.MemberAccessNode memberAccess) { + // Try to build qualified name and resolve as type + String qualifiedName = buildQualifiedName(memberAccess); + if (qualifiedName != null) { + TypeInfo typeInfo = context.resolveTypeName(qualifiedName); + if (typeInfo != null && typeInfo.isResolved()) { + TypeInfo classRef = new ClassTypeInfo(typeInfo); + return new MethodReferenceTarget(classRef, true); + } + } + + // Try resolving as expression chain (object.field::method) + TypeInfo exprType = resolveNodeType(memberAccess); + if (exprType != null && exprType.isResolved()) { + boolean isClassRef = exprType.isClassReference(); + return new MethodReferenceTarget(exprType, isClassRef); + } + + return null; + } + + /** + * Build a qualified name string from a MemberAccessNode chain. + * Example: a.b.c.ClassName -> "a.b.c.ClassName" + */ + private String buildQualifiedName(ExpressionNode node) { + if (node instanceof ExpressionNode.IdentifierNode) { + return ((ExpressionNode.IdentifierNode) node).getName(); + } + + if (node instanceof ExpressionNode.MemberAccessNode) { + ExpressionNode.MemberAccessNode ma = (ExpressionNode.MemberAccessNode) node; + String baseName = buildQualifiedName(ma.getTarget()); + if (baseName != null) { + return baseName + "." + ma.getMemberName(); + } + } + + return null; + } + + /** + * Resolve a method reference (target::methodName). + */ + private TypeInfo resolveMethodReference(MethodReferenceTarget target, String methodName, + MethodInfo sam, TypeInfo expectedType) { + TypeInfo targetType = target.isClassRef && target.type instanceof ClassTypeInfo + ? ((ClassTypeInfo) target.type).getInstanceType() + : target.type; + + if (targetType == null || !targetType.hasMethod(methodName)) { + return TypeInfo.fromClass(Object.class); + } + + // Find best matching method using OverloadSelector + MethodInfo method = findBestMethodForReference(targetType, methodName, sam, target.isClassRef); + if (method == null) { + return TypeInfo.fromClass(Object.class); + } + + // Validate signature compatibility + String error = validateMethodSignature(method, sam, target.isClassRef, targetType); + if (error != null) { + return TypeInfo.fromClass(Object.class); + } + + return expectedType; + } + + /** + * Find the best matching method for a method reference using OverloadSelector logic. + */ + private MethodInfo findBestMethodForReference(TypeInfo targetType, String methodName, + MethodInfo sam, boolean isClassRef) { + List allOverloads = targetType.getAllMethodOverloads(methodName); + if (allOverloads.isEmpty()) { + return null; + } + + // Extract SAM parameter types for overload matching + TypeInfo[] samParamTypes = extractSamParamTypes(sam, isClassRef, targetType); + + // Filter candidates by arity first, then use OverloadSelector + List candidates = new ArrayList<>(); + int expectedArity = samParamTypes.length; + + for (MethodInfo method : allOverloads) { + int methodArity = method.getParameters().size(); + + // Direct match: instance::method or ClassName::staticMethod + if (methodArity == expectedArity) { + candidates.add(method); + } + // Unbound instance method: ClassName::instanceMethod (first SAM param is receiver) + else if (isClassRef && methodArity == sam.getParameters().size() - 1 && !isStaticMethod(method)) { + candidates.add(method); + } + } + + if (candidates.isEmpty()) { + return null; + } + + if (candidates.size() == 1) { + return candidates.get(0); + } + + // Use OverloadSelector for multi-candidate selection + return OverloadSelector.selectBestOverload(candidates, samParamTypes); + } + + /** + * Extract the effective parameter types from SAM for method matching. + * For unbound instance methods, excludes the first receiver parameter. + */ + private TypeInfo[] extractSamParamTypes(MethodInfo sam, boolean isClassRef, TypeInfo targetType) { + List samParams = sam.getParameters(); + + // For class references, we might be matching an unbound instance method + // where the first SAM param is the receiver - handled in findBestMethodForReference + TypeInfo[] types = new TypeInfo[samParams.size()]; + for (int i = 0; i < samParams.size(); i++) { + types[i] = samParams.get(i).getTypeInfo(); + } + return types; + } + + /** + * Check if a method is static (Java reflection). + */ + private boolean isStaticMethod(MethodInfo method) { + java.lang.reflect.Method javaMethod = method.getJavaMethod(); + if (javaMethod != null) { + return java.lang.reflect.Modifier.isStatic(javaMethod.getModifiers()); + } + // For script-defined methods, assume non-static unless marked + return method.isStatic(); + } + + /** + * Validate method signature compatibility with SAM. + */ + private String validateMethodSignature(MethodInfo method, MethodInfo sam, + boolean isClassRef, TypeInfo targetType) { + List methodParams = method.getParameters(); + List samParams = sam.getParameters(); + + int methodArity = methodParams.size(); + int samArity = samParams.size(); + + // Check for unbound instance method reference (ClassName::instanceMethod) + boolean isUnbound = isClassRef && methodArity == samArity - 1 && !isStaticMethod(method); + + // Validate parameter count + if (methodArity != samArity && !isUnbound) { + return "Parameter count mismatch"; + } + + // Validate parameter types + int offset = isUnbound ? 1 : 0; + for (int i = 0; i < methodArity; i++) { + TypeInfo methodParamType = methodParams.get(i).getTypeInfo(); + TypeInfo samParamType = samParams.get(i + offset).getTypeInfo(); + + if (methodParamType != null && samParamType != null) { + if (!TypeChecker.isTypeCompatible(methodParamType, samParamType)) { + return "Parameter type mismatch at position " + (i + 1); + } + } + } + + // For unbound reference, validate receiver compatibility + if (isUnbound && samArity > 0) { + TypeInfo firstSamParam = samParams.get(0).getTypeInfo(); + if (firstSamParam != null && !TypeChecker.isTypeCompatible(targetType, firstSamParam)) { + return "Receiver type mismatch"; + } + } + + // Validate return type (covariant) + TypeInfo methodReturn = method.getReturnType(); + TypeInfo samReturn = sam.getReturnType(); + + if (samReturn != null && methodReturn != null) { + boolean samIsVoid = "void".equals(samReturn.getFullName()) || samReturn.getJavaClass() == void.class; + if (!samIsVoid && !TypeChecker.isTypeCompatible(samReturn, methodReturn)) { + return "Return type mismatch"; + } + } + + return null; + } + + /** + * Resolve a constructor reference (ClassName::new or Type[]::new). + */ + private TypeInfo resolveConstructorReference(MethodReferenceTarget target, MethodInfo sam, + TypeInfo expectedType) { + TypeInfo targetType = target.type; + + // Handle array constructor reference: Type[]::new + if (isArrayType(targetType)) { + return resolveArrayConstructorReference(targetType, sam, expectedType); + } + + // Get the actual class type for constructor lookup + TypeInfo classType = target.type instanceof ClassTypeInfo + ? ((ClassTypeInfo) target.type).getInstanceType() + : target.type; + + if (classType == null) { + return TypeInfo.fromClass(Object.class); + } + + // Cannot construct interfaces or abstract classes + if (classType.getKind() == TypeInfo.Kind.INTERFACE) { + return TypeInfo.fromClass(Object.class); + } + + // Extract SAM parameter types for constructor matching + List samParams = sam.getParameters(); + TypeInfo[] paramTypes = new TypeInfo[samParams.size()]; + for (int i = 0; i < samParams.size(); i++) { + paramTypes[i] = samParams.get(i).getTypeInfo(); + } + + // Find matching constructor + MethodInfo constructor = classType.findConstructor(paramTypes); + if (constructor == null && samParams.size() > 0) { + // Try by arity if exact type match fails + constructor = classType.findConstructor(samParams.size()); + } + + if (constructor == null) { + return TypeInfo.fromClass(Object.class); + } + + // Validate return type compatibility + TypeInfo samReturn = sam.getReturnType(); + if (samReturn != null && !TypeChecker.isTypeCompatible(samReturn, classType)) { + return TypeInfo.fromClass(Object.class); + } + + return expectedType; + } + + /** + * Resolve an array constructor reference: Type[]::new + * Must match IntFunction or similar single-int-param functional interface. + */ + private TypeInfo resolveArrayConstructorReference(TypeInfo arrayType, MethodInfo sam, + TypeInfo expectedType) { + List samParams = sam.getParameters(); + + // Array constructor takes exactly one int parameter (the size) + if (samParams.size() != 1) { + return TypeInfo.fromClass(Object.class); + } + + TypeInfo sizeParam = samParams.get(0).getTypeInfo(); + if (sizeParam == null || !isIntLike(sizeParam)) { + return TypeInfo.fromClass(Object.class); + } + + // Return type must be compatible with array type + TypeInfo samReturn = sam.getReturnType(); + if (samReturn != null && !TypeChecker.isTypeCompatible(samReturn, arrayType)) { + return TypeInfo.fromClass(Object.class); + } + + return expectedType; + } + + /** + * Check if a type is an array type. + */ + private boolean isArrayType(TypeInfo type) { + if (type == null) return false; + String name = type.getFullName(); + return name != null && name.endsWith("[]"); + } + + /** + * Check if a type is int-like (int, Integer, or numeric that can represent array size). + */ + private boolean isIntLike(TypeInfo type) { + if (type == null) return false; + String name = type.getFullName(); + return "int".equals(name) || "java.lang.Integer".equals(name) + || "long".equals(name) || "java.lang.Long".equals(name); + } + + public static ExpressionNode.TypeResolverContext createBasicContext() { + return new ExpressionNode.TypeResolverContext() { + public TypeInfo resolveIdentifier(String name) { + if ("true".equals(name) || "false".equals(name)) return TypeInfo.fromPrimitive("boolean"); + if ("null".equals(name)) return TypeInfo.unresolved("null", ""); + return null; + } + public TypeInfo resolveMemberAccess(TypeInfo targetType, String memberName) { return null; } + public TypeInfo resolveMethodCall(TypeInfo targetType, String methodName, TypeInfo[] argTypes) { return null; } + public TypeInfo resolveArrayAccess(TypeInfo arrayType) { + String typeName = arrayType.getFullName(); + if (typeName.endsWith("[]")) { + // For primitive arrays, we need to map back to primitive TypeInfo + String elementType = typeName.substring(0, typeName.length() - 2); + switch (elementType) { + case "int": case "long": case "float": case "double": + case "byte": case "short": case "char": case "boolean": + return TypeInfo.fromPrimitive(elementType); + default: + return TypeInfo.unresolved(elementType, elementType); + } + } + return null; + } + public TypeInfo resolveTypeName(String typeName) { + // Handle primitives + switch (typeName) { + case "int": case "long": case "float": case "double": + case "byte": case "short": case "char": case "boolean": case "void": + return TypeInfo.fromPrimitive(typeName); + default: + return TypeInfo.unresolved(typeName, typeName); + } + } + }; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/OperatorType.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/OperatorType.java new file mode 100644 index 000000000..2d4bf32dc --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/OperatorType.java @@ -0,0 +1,147 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +public enum OperatorType { + + ADD("+", 11, Associativity.LEFT, Category.ARITHMETIC), + SUBTRACT("-", 11, Associativity.LEFT, Category.ARITHMETIC), + MULTIPLY("*", 12, Associativity.LEFT, Category.ARITHMETIC), + DIVIDE("/", 12, Associativity.LEFT, Category.ARITHMETIC), + MODULO("%", 12, Associativity.LEFT, Category.ARITHMETIC), + + + ASSIGN("=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + ADD_ASSIGN("+=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + SUBTRACT_ASSIGN("-=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + MULTIPLY_ASSIGN("*=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + DIVIDE_ASSIGN("/=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + MODULO_ASSIGN("%=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + AND_ASSIGN("&=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + OR_ASSIGN("|=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + XOR_ASSIGN("^=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + LEFT_SHIFT_ASSIGN("<<=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + RIGHT_SHIFT_ASSIGN(">>=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + UNSIGNED_RIGHT_SHIFT_ASSIGN(">>>=", 1, Associativity.RIGHT, Category.ASSIGNMENT), + + TERNARY_QUESTION("?", 2, Associativity.RIGHT, Category.TERNARY), + TERNARY_COLON(":", 2, Associativity.RIGHT, Category.TERNARY), + + LOGICAL_OR("||", 3, Associativity.LEFT, Category.LOGICAL), + LOGICAL_AND("&&", 4, Associativity.LEFT, Category.LOGICAL), + + BITWISE_OR("|", 5, Associativity.LEFT, Category.BITWISE), + BITWISE_XOR("^", 6, Associativity.LEFT, Category.BITWISE), + BITWISE_AND("&", 7, Associativity.LEFT, Category.BITWISE), + + EQUALS("==", 8, Associativity.LEFT, Category.RELATIONAL), + NOT_EQUALS("!=", 8, Associativity.LEFT, Category.RELATIONAL), + LESS_THAN("<", 9, Associativity.LEFT, Category.RELATIONAL), + GREATER_THAN(">", 9, Associativity.LEFT, Category.RELATIONAL), + LESS_THAN_OR_EQUAL("<=", 9, Associativity.LEFT, Category.RELATIONAL), + GREATER_THAN_OR_EQUAL(">=", 9, Associativity.LEFT, Category.RELATIONAL), + + LEFT_SHIFT("<<", 10, Associativity.LEFT, Category.BITWISE), + RIGHT_SHIFT(">>", 10, Associativity.LEFT, Category.BITWISE), + UNSIGNED_RIGHT_SHIFT(">>>", 10, Associativity.LEFT, Category.BITWISE), + + UNARY_PLUS("+", 14, Associativity.RIGHT, Category.UNARY), + UNARY_MINUS("-", 14, Associativity.RIGHT, Category.UNARY), + LOGICAL_NOT("!", 14, Associativity.RIGHT, Category.UNARY), + BITWISE_NOT("~", 14, Associativity.RIGHT, Category.UNARY), + PRE_INCREMENT("++", 14, Associativity.RIGHT, Category.UNARY), + PRE_DECREMENT("--", 14, Associativity.RIGHT, Category.UNARY), + POST_INCREMENT("++", 15, Associativity.LEFT, Category.UNARY_POSTFIX), + POST_DECREMENT("--", 15, Associativity.LEFT, Category.UNARY_POSTFIX), + + MEMBER_ACCESS(".", 16, Associativity.LEFT, Category.ACCESS), + ARRAY_ACCESS("[]", 16, Associativity.LEFT, Category.ACCESS), + METHOD_CALL("()", 16, Associativity.LEFT, Category.ACCESS), + + CAST("(type)", 14, Associativity.RIGHT, Category.CAST), + INSTANCEOF("instanceof", 9, Associativity.LEFT, Category.INSTANCEOF); + + + private final String symbol; + private final int precedence; + private final Associativity associativity; + private final Category category; + + OperatorType(String symbol, int precedence, Associativity associativity, Category category) { + this.symbol = symbol; + this.precedence = precedence; + this.associativity = associativity; + this.category = category; + } + + public String getSymbol() { return symbol; } + public int getPrecedence() { return precedence; } + public Associativity getAssociativity() { return associativity; } + public Category getCategory() { return category; } + + public boolean isBinary() { + return category == Category.ARITHMETIC || category == Category.RELATIONAL || + category == Category.LOGICAL || category == Category.BITWISE; + } + + public boolean isUnary() { + return category == Category.UNARY || category == Category.UNARY_POSTFIX; + } + + public boolean isAssignment() { + return category == Category.ASSIGNMENT; + } + + public boolean isComparison() { + return category == Category.RELATIONAL; + } + + public enum Associativity { LEFT, RIGHT } + + public enum Category { + ARITHMETIC, UNARY, UNARY_POSTFIX, RELATIONAL, LOGICAL, BITWISE, + TERNARY, INSTANCEOF, ASSIGNMENT, CAST, ACCESS + } + + public static OperatorType fromSymbol(String symbol) { + for (OperatorType op : values()) { + if (op.symbol.equals(symbol)) return op; + } + return null; + } + + public static OperatorType fromBinarySymbol(String symbol) { + switch (symbol) { + case "+": return ADD; + case "-": return SUBTRACT; + case "*": return MULTIPLY; + case "/": return DIVIDE; + case "%": return MODULO; + case "==": return EQUALS; + case "!=": return NOT_EQUALS; + case "<": return LESS_THAN; + case ">": return GREATER_THAN; + case "<=": return LESS_THAN_OR_EQUAL; + case ">=": return GREATER_THAN_OR_EQUAL; + case "&&": return LOGICAL_AND; + case "||": return LOGICAL_OR; + case "&": return BITWISE_AND; + case "|": return BITWISE_OR; + case "^": return BITWISE_XOR; + case "<<": return LEFT_SHIFT; + case ">>": return RIGHT_SHIFT; + case ">>>": return UNSIGNED_RIGHT_SHIFT; + case "=": return ASSIGN; + case "+=": return ADD_ASSIGN; + case "-=": return SUBTRACT_ASSIGN; + case "*=": return MULTIPLY_ASSIGN; + case "/=": return DIVIDE_ASSIGN; + case "%=": return MODULO_ASSIGN; + case "&=": return AND_ASSIGN; + case "|=": return OR_ASSIGN; + case "^=": return XOR_ASSIGN; + case "<<=": return LEFT_SHIFT_ASSIGN; + case ">>=": return RIGHT_SHIFT_ASSIGN; + case ">>>=": return UNSIGNED_RIGHT_SHIFT_ASSIGN; + default: return null; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/TypeRules.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/TypeRules.java new file mode 100644 index 000000000..ad1428088 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/expression/TypeRules.java @@ -0,0 +1,291 @@ +package noppes.npcs.client.gui.util.script.interpreter.expression; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +public class TypeRules { + + public static boolean isNumeric(TypeInfo type) { + if (type == null || !type.isResolved()) return false; + String name = type.getSimpleName(); + return "int".equals(name) || "long".equals(name) || "float".equals(name) || + "double".equals(name) || "byte".equals(name) || "short".equals(name) || "char".equals(name); + } + + public static boolean isIntegral(TypeInfo type) { + if (type == null || !type.isResolved()) return false; + String name = type.getSimpleName(); + return "int".equals(name) || "long".equals(name) || "byte".equals(name) || + "short".equals(name) || "char".equals(name); + } + + public static boolean isFloatingPoint(TypeInfo type) { + if (type == null || !type.isResolved()) return false; + String name = type.getSimpleName(); + return "float".equals(name) || "double".equals(name); + } + + public static boolean isBoolean(TypeInfo type) { + if (type == null || !type.isResolved()) return false; + return "boolean".equals(type.getSimpleName()); + } + + public static boolean isString(TypeInfo type) { + if (type == null || !type.isResolved()) return false; + String name = type.getSimpleName(); + String fullName = type.getFullName(); + return "String".equals(name) || "java.lang.String".equals(fullName); + } + + public static TypeInfo binaryNumericPromotion(TypeInfo left, TypeInfo right) { + if (left == null || right == null || !left.isResolved() || !right.isResolved()) { + return null; + } + String l = left.getSimpleName(); + String r = right.getSimpleName(); + if ("double".equals(l) || "double".equals(r)) return TypeInfo.fromPrimitive("double"); + if ("float".equals(l) || "float".equals(r)) return TypeInfo.fromPrimitive("float"); + if ("long".equals(l) || "long".equals(r)) return TypeInfo.fromPrimitive("long"); + return TypeInfo.fromPrimitive("int"); + } + + public static TypeInfo unaryNumericPromotion(TypeInfo type) { + if (type == null || !type.isResolved()) return null; + String name = type.getSimpleName(); + if ("double".equals(name) || "float".equals(name) || "long".equals(name)) return type; + if ("byte".equals(name) || "short".equals(name) || "char".equals(name) || "int".equals(name)) { + return TypeInfo.fromPrimitive("int"); + } + return null; + } + + public static TypeInfo resolveBinaryOperatorType(OperatorType op, TypeInfo left, TypeInfo right) { + if (op == null) return null; + + switch (op.getCategory()) { + case ARITHMETIC: + if (op == OperatorType.ADD && (isString(left) || isString(right))) { + return validateAgainstExpectedType(TypeInfo.fromClass(String.class)); + } + if (isNumeric(left) && isNumeric(right)) { + TypeInfo promoted = binaryNumericPromotion(left, right); + return validateAgainstExpectedType(promoted); + } + return null; + + case RELATIONAL: + if (op == OperatorType.EQUALS || op == OperatorType.NOT_EQUALS) { + return TypeInfo.fromPrimitive("boolean"); + } + if (isNumeric(left) && isNumeric(right)) { + return TypeInfo.fromPrimitive("boolean"); + } + return null; + + case LOGICAL: + if (isBoolean(left) && isBoolean(right)) { + return TypeInfo.fromPrimitive("boolean"); + } + return null; + + case BITWISE: + if (op == OperatorType.LEFT_SHIFT || op == OperatorType.RIGHT_SHIFT || + op == OperatorType.UNSIGNED_RIGHT_SHIFT) { + if (isIntegral(left)) { + TypeInfo promoted = unaryNumericPromotion(left); + return validateAgainstExpectedType(promoted); + } + } + if (isIntegral(left) && isIntegral(right)) { + TypeInfo promoted = binaryNumericPromotion(left, right); + return validateAgainstExpectedType(promoted); + } + if (isBoolean(left) && isBoolean(right)) { + return TypeInfo.fromPrimitive("boolean"); + } + return null; + + case ASSIGNMENT: + return left; + + default: + return null; + } + } + + public static TypeInfo resolveUnaryOperatorType(OperatorType op, TypeInfo operand) { + if (op == null || operand == null || !operand.isResolved()) { + return null; + } + + switch (op) { + case UNARY_PLUS: + case UNARY_MINUS: + if (isNumeric(operand)) { + TypeInfo promoted = unaryNumericPromotion(operand); + return validateAgainstExpectedType(promoted); + } + return null; + + case BITWISE_NOT: + if (isIntegral(operand)) { + TypeInfo promoted = unaryNumericPromotion(operand); + return validateAgainstExpectedType(promoted); + } + return null; + + case LOGICAL_NOT: + if (isBoolean(operand)) return TypeInfo.fromPrimitive("boolean"); + return null; + + case PRE_INCREMENT: + case PRE_DECREMENT: + case POST_INCREMENT: + case POST_DECREMENT: + if (isNumeric(operand)) return operand; + return null; + + default: + return null; + } + } + + /** + * Check if sourceType can be assigned to targetType. + * Handles primitives, numeric conversions, null compatibility, and reference types. + */ + public static boolean isAssignmentCompatible(TypeInfo sourceType, TypeInfo targetType) { + if (sourceType == null || targetType == null) + return false; + if (!targetType.isResolved()) + return true; // Can't validate against unresolved type + + // Null literal can be assigned to any reference type + if ("".equals(sourceType.getFullName())) { + return !isPrimitive(targetType); + } + + if (!sourceType.isResolved()) + return false; + + // Exact match + if (sourceType.equals(targetType)) + return true; + if (sourceType.getFullName().equals(targetType.getFullName())) + return true; + + // Numeric conversions (widening primitive conversions) + String source = sourceType.getSimpleName(); + String target = targetType.getSimpleName(); + + // byte -> short, int, long, float, double + if ("byte".equals(source)) { + return "short".equals(target) || "int".equals(target) || "long".equals(target) || + "float".equals(target) || "double".equals(target); + } + // short -> int, long, float, double + if ("short".equals(source)) { + return "int".equals(target) || "long".equals(target) || + "float".equals(target) || "double".equals(target); + } + // char -> int, long, float, double + if ("char".equals(source)) { + return "int".equals(target) || "long".equals(target) || + "float".equals(target) || "double".equals(target); + } + // int -> long, float, double + if ("int".equals(source)) { + return "long".equals(target) || "float".equals(target) || "double".equals(target); + } + // long -> float, double + if ("long".equals(source)) { + return "float".equals(target) || "double".equals(target); + } + // float -> double + if ("float".equals(source)) { + return "double".equals(target); + } + + // For reference types, we'd need inheritance/interface checking + // For now, return false for incompatible types + return false; + } + + /** + * Check if a type is a primitive type. + */ + public static boolean isPrimitive(TypeInfo type) { + if (type == null || !type.isResolved()) + return false; + String name = type.getSimpleName(); + return "int".equals(name) || "long".equals(name) || "float".equals(name) || + "double".equals(name) || "byte".equals(name) || "short".equals(name) || + "char".equals(name) || "boolean".equals(name) || "void".equals(name); + } + + /** + * Validate a computed type against the current expected type context. + * If there's an expected type and the computed type is compatible, returns the expected type. + * If incompatible, returns the computed type so the error can be properly reported. + * If no expected type context, returns the computed type unchanged. + * + * @param computedType The type computed by normal type rules + * @return The type to use (either expectedType if compatible, or computedType) + */ + public static TypeInfo validateAgainstExpectedType(TypeInfo computedType) { + if (computedType == null) return null; + + TypeInfo expectedType = ExpressionTypeResolver.CURRENT_EXPECTED_TYPE; + if (expectedType != null && expectedType.isResolved() && computedType.isResolved()) { + if (isAssignmentCompatible(computedType, expectedType)) { + return expectedType; + } + // Return computed type so error can be detected (incompatible with expected) + return computedType; + } + + return computedType; + } + + public static TypeInfo resolveTernaryType(TypeInfo thenType, TypeInfo elseType) { + if (thenType == null && elseType == null) return null; + if (thenType == null) return elseType; + if (elseType == null) return thenType; + + // Handle null literal type + boolean thenIsNull = "".equals(thenType.getFullName()); + boolean elseIsNull = "".equals(elseType.getFullName()); + + if (thenIsNull && elseIsNull) return thenType; // both null -> null + if (thenIsNull) return elseType; // null : T -> T + if (elseIsNull) return thenType; // T : null -> T + + if (!thenType.isResolved()) return elseType.isResolved() ? elseType : null; + if (!elseType.isResolved()) return thenType; + + // If there's an expected type context, validate both branches against it + TypeInfo expectedType = ExpressionTypeResolver.CURRENT_EXPECTED_TYPE; + if (expectedType != null && expectedType.isResolved()) { + boolean thenCompatible = isAssignmentCompatible(thenType, expectedType); + boolean elseCompatible = isAssignmentCompatible(elseType, expectedType); + + // If both branches are compatible with expected type, return expected type + if (thenCompatible && elseCompatible) { + return expectedType; + } else if (!elseCompatible) { + // Return the incompatible type so error can be detected + return elseType; + } else { + // thenType is incompatible + return thenType; + } + } + + // Normal ternary type resolution without expected type context + if (thenType.equals(elseType)) return thenType; + if (isNumeric(thenType) && isNumeric(elseType)) { + return binaryNumericPromotion(thenType, elseType); + } + + return thenType; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/AssignmentInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/AssignmentInfo.java new file mode 100644 index 000000000..c1eaeef94 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/AssignmentInfo.java @@ -0,0 +1,243 @@ +package noppes.npcs.client.gui.util.script.interpreter.field; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; + +/** + * Metadata for assignment validation. + * Tracks assignment statements like "varName = expr" or "obj.field = expr" + * and validates type compatibility, access modifiers, and final status. + * + * Each AssignmentInfo represents a single assignment to a field. + * The positions are: + * - lhsStart/lhsEnd: The target variable/field position (left-hand side) + * - rhsStart/rhsEnd: From first token of RHS to the semicolon (inclusive) + */ +public class AssignmentInfo { + + /** + * Error type for assignment validation. + */ + public enum ErrorType { + NONE, + TYPE_MISMATCH, // Assigned value type doesn't match target type + FINAL_REASSIGNMENT, // Attempting to reassign a final field + PRIVATE_ACCESS, // Attempting to access private field + PROTECTED_ACCESS, // Attempting to access protected field from invalid context + UNRESOLVED_TARGET, // Target variable/field doesn't exist + STATIC_CONTEXT_ERROR, // Accessing instance field from static context + DUPLICATE_DECLARATION // Variable is already defined in the scope + } + + // Statement position + private final int statementStart; // Absolute start of statement (includes modifiers/type for declarations) + + // Target/LHS info + private final String targetName; + private final int lhsStart; // Position of first char of target variable/field + private final int lhsEnd; // Position after last char of target + private final TypeInfo targetType; // Declared type of target + + // Source/RHS info (from first token of RHS expression to semicolon) + private final int rhsStart; // Position of first non-whitespace char after '=' + private final int rhsEnd; // Position of ';' (inclusive in the range for underlining) + private final TypeInfo sourceType; // Resolved type of RHS expression + private final String sourceExpr; // The RHS expression text (for display) + + // For chained field access (e.g., obj.field = value) + private final TypeInfo receiverType; // Type of receiver (null for simple variables) + private final Field reflectionField; // Java reflection field (for modifier checks) + + // Flag for script-defined final variables + private final boolean isFinal; + + // Validation + private ErrorType errorType = ErrorType.NONE; + private String errorMessage; + private String requiredType; + private String providedType; + + public AssignmentInfo(String targetName, int statementStart, int lhsStart, int lhsEnd, + TypeInfo targetType, int rhsStart, int rhsEnd, + TypeInfo sourceType, String sourceExpr, + TypeInfo receiverType, java.lang.reflect.Field reflectionField, + boolean isFinal) { + this.targetName = targetName; + this.statementStart = statementStart; + this.lhsStart = lhsStart; + this.lhsEnd = lhsEnd; + this.targetType = targetType; + this.rhsStart = rhsStart; + this.rhsEnd = rhsEnd; + this.sourceType = sourceType; + this.sourceExpr = sourceExpr; + this.receiverType = receiverType; + this.reflectionField = reflectionField; + this.isFinal = isFinal; + } + + /** + * Factory method to create an AssignmentInfo representing a duplicate declaration error. + * Only the LHS (variable name) position is relevant for underlining. + */ + public static AssignmentInfo duplicateDeclaration(String varName, int nameStart, int nameEnd, String errorMessage) { + AssignmentInfo info = new AssignmentInfo( + varName, + nameStart, // statementStart = nameStart for underline positioning + nameStart, // lhsStart + nameEnd, // lhsEnd + null, // targetType + -1, // rhsStart (not applicable) + -1, // rhsEnd (not applicable) + null, // sourceType + null, // sourceExpr + null, // receiverType + null, // reflectionField + false // isFinal + ); + info.setError(ErrorType.DUPLICATE_DECLARATION, errorMessage); + return info; + } + + // ==================== VALIDATION ==================== + + /** + * Validate this assignment. + * Checks type compatibility, final status, and access modifiers. + */ + public void validate() { + // Check for final field reassignment from reflection + if (reflectionField != null && Modifier.isFinal(reflectionField.getModifiers())) { + setError(ErrorType.FINAL_REASSIGNMENT, + "Cannot assign a value to final variable '" + targetName + "'"); + return; + } + + // Check for final field reassignment from FieldInfo (script-defined finals) + if (isFinal) { + setError(ErrorType.FINAL_REASSIGNMENT, + "Cannot assign a value to final variable '" + targetName + "'"); + return; + } + + // Check access modifiers for chained field access + if (reflectionField != null && receiverType != null) { + int mods = reflectionField.getModifiers(); + if (Modifier.isPrivate(mods)) { + setError(ErrorType.PRIVATE_ACCESS, + "'" + targetName + "' has private access in '" + receiverType.getFullName() + "'"); + return; + } + } + + // Check type compatibility + if (targetType != null && sourceType != null) { + if (!TypeChecker.isTypeCompatible(targetType, sourceType)) { + this.requiredType = targetType.getSimpleName(); + this.providedType = sourceType.getSimpleName(); + setError(ErrorType.TYPE_MISMATCH, buildTypeMismatchMessage()); + } + } + } + + /** + * Build a formatted type mismatch error message (IntelliJ style). + */ + private String buildTypeMismatchMessage() { + return "Provided type: " + providedType + "\nRequired: " + requiredType; + } + + private void setError(ErrorType type, String message) { + this.errorType = type; + this.errorMessage = message; + } + + // ==================== POSITION CHECKS ==================== + + /** + * Check if the given global position falls within the LHS (target) of this assignment. + */ + public boolean containsLhsPosition(int position) { + return position >= statementStart && position < lhsEnd; + } + + /** + * Check if the given global position falls within the RHS (source) of this assignment. + */ + public boolean containsRhsPosition(int position) { + return position >= rhsStart && position <= rhsEnd; + } + + /** + * Check if the given global position falls anywhere in this assignment (LHS or RHS). + */ + public boolean containsPosition(int position) { + return position >= statementStart && position <= rhsEnd; + } + + // ==================== ERROR TYPE CHECKS ==================== + + /** + * Check if this is an LHS error (final reassignment, access errors, duplicate declarations). + * These errors should underline the LHS. + */ + public boolean isLhsError() { + return errorType == ErrorType.FINAL_REASSIGNMENT || + errorType == ErrorType.PRIVATE_ACCESS || + errorType == ErrorType.PROTECTED_ACCESS || + errorType == ErrorType.STATIC_CONTEXT_ERROR || + errorType == ErrorType.DUPLICATE_DECLARATION; + } + + /** + * Check if this is an RHS error (type mismatch). + * These errors should underline the RHS. + */ + public boolean isRhsError() { + return false; + } + + public boolean isFullLineError() { + return errorType == ErrorType.TYPE_MISMATCH; + } + + // ==================== GETTERS ==================== + + public String getTargetName() { return targetName; } + public int getStatementStart() { return statementStart; } + public int getLhsStart() { return lhsStart; } + public int getLhsEnd() { return lhsEnd; } + public TypeInfo getTargetType() { return targetType; } + public int getRhsStart() { return rhsStart; } + public int getRhsEnd() { return rhsEnd; } + public TypeInfo getSourceType() { return sourceType; } + public String getSourceExpr() { return sourceExpr; } + public TypeInfo getReceiverType() { return receiverType; } + public java.lang.reflect.Field getReflectionField() { return reflectionField; } + + public ErrorType getErrorType() { return errorType; } + public String getErrorMessage() { return errorMessage; } + public String getRequiredType() { return requiredType; } + public String getProvidedType() { return providedType; } + + public boolean hasError() { return errorType != ErrorType.NONE; } + public boolean hasTypeMismatch() { return errorType == ErrorType.TYPE_MISMATCH; } + public boolean hasFinalReassignment() { return errorType == ErrorType.FINAL_REASSIGNMENT; } + public boolean hasAccessError() { return errorType == ErrorType.PRIVATE_ACCESS || errorType == ErrorType.PROTECTED_ACCESS; } + + @Override + public String toString() { + return "AssignmentInfo{" + + "target='" + targetName + "', " + + "stmt=" + statementStart + ", " + + "lhs=[" + lhsStart + "-" + lhsEnd + "], " + + "rhs=[" + rhsStart + "-" + rhsEnd + "], " + + "targetType=" + targetType + ", " + + "sourceType=" + sourceType + ", " + + "error=" + errorType + + '}'; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/EnumConstantInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/EnumConstantInfo.java new file mode 100644 index 000000000..f86b3e146 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/EnumConstantInfo.java @@ -0,0 +1,317 @@ +package noppes.npcs.client.gui.util.script.interpreter.field; + +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represents an enum constant declaration with its constructor call. + * Combines a FieldInfo (the constant itself) with a MethodCallInfo (the constructor call validation). + * This allows us to reuse existing method call validation logic for enum constructor matching. + */ +public class EnumConstantInfo { + + private final FieldInfo fieldInfo; + private final MethodCallInfo constructorCall; // Can be null if no args provided + private final TypeInfo enumType; + + public EnumConstantInfo(FieldInfo fieldInfo, MethodCallInfo constructorCall, TypeInfo enumType) { + this.fieldInfo = fieldInfo; + this.constructorCall = constructorCall; + this.enumType = enumType; + } + + /** + * Parse enum constants from an enum body. + * Returns a list of EnumConstantInfo objects, each representing one constant. + * + * @param enumType The enum type being parsed + * @param bodyText The text content of the enum body + * @param bodyOffset The absolute offset where the enum body starts + * @param keywordPattern Pattern to detect Java keywords (to skip them) + * @return List of parsed enum constants + */ + public static List parseEnumConstants( + ScriptTypeInfo enumType, + String bodyText, + int bodyOffset, + Pattern keywordPattern) { + + List constants = new ArrayList<>(); + + // Find where enum constants end (first semicolon not in parens, or first method/field declaration) + int constantsEnd = findEnumConstantsEnd(bodyText); + if (constantsEnd <= 0) { + // No semicolon found - entire body might be constants + constantsEnd = bodyText.length(); + } + + // Pattern to match enum constants: NAME or NAME() or NAME(args) + Pattern constantPattern = Pattern.compile( + "([A-Za-z_][a-zA-Z0-9_]*)\\s*(\\(([^)]*)\\))?"); + + Matcher m = constantPattern.matcher(bodyText); + int lastEnd = 0; + + while (m.find()) { + // Skip if we're past the constants section + if (m.start() >= constantsEnd) { + break; + } + + int absPos = bodyOffset + m.start(); + if (ScriptDocument.INSTANCE.isExcluded(absPos)) { + lastEnd = m.end(); + continue; + } + + // Check if this is at a valid position (after comma or at start) + String beforeMatch = bodyText.substring(lastEnd, m.start()).trim(); + if (!beforeMatch.isEmpty() && !beforeMatch.equals(",")) { + // Not a valid enum constant position - might be inside parens + continue; + } + + String constantName = m.group(1); + String argsClause = m.group(2); // includes parens if present + String args = m.group(3); // just the args without parens + + // Skip keywords + if (keywordPattern.matcher(constantName).matches()) { + lastEnd = m.end(); + continue; + } + + // Determine init range (the constructor arguments) + int initStart = -1; + int initEnd = -1; + + // Create MethodCallInfo for constructor validation if args present + MethodCallInfo constructorCall = null; + if (argsClause != null && !argsClause.isEmpty()) { + initStart = bodyOffset + m.start(2); // Position of '(' + initEnd = bodyOffset + m.end(2) - 1; // Position after ')' + + constructorCall = createConstructorCall( + enumType, + constantName, + absPos, + initStart, + initEnd + ); + } else if (enumType.hasConstructors()) { + // No args provided, but enum has constructors - validate against no-arg constructor + initStart = absPos + constantName.length(); + initEnd = initStart; + + constructorCall = createConstructorCall( + enumType, + constantName, + absPos, + initStart, + initEnd + ); + } + + + // Create FieldInfo for the enum constant + FieldInfo fieldInfo = FieldInfo.enumConstant( + constantName, + enumType, + absPos, + initStart, + initEnd, + null + ); + + EnumConstantInfo constantInfo = new EnumConstantInfo(fieldInfo, constructorCall, enumType); + fieldInfo.setEnumConstantInfo(constantInfo); + constants.add(constantInfo); + + lastEnd = m.end(); + } + + return constants; + } + + /** + * Create a MethodCallInfo for an enum constant's constructor call. + * Validates the arguments against available constructors. + */ + private static MethodCallInfo createConstructorCall( + ScriptTypeInfo enumType, + String constantName, + int constantStart, + int openParenPos, + int closeParenPos) { + + List constructors = enumType.getConstructors(); + + // Parse arguments - pass absolute positions (bodyOffset accounts for the offset into the document) + List arguments = ScriptDocument.INSTANCE.parseMethodArguments( + openParenPos + 1, + closeParenPos, + null + ); + + // Find matching constructor + MethodInfo matchedConstructor = null; + List candidates = new ArrayList<>(); + + for (MethodInfo constructor : constructors) { + candidates.add(constructor); + if (constructor.getParameterCount() == arguments.size()) { + matchedConstructor = constructor; + break; + } + } + + // Create MethodCallInfo + MethodCallInfo callInfo = new MethodCallInfo( + constantName, // Use constant name as "method" name + constantStart, + openParenPos, + openParenPos, + closeParenPos, + arguments, + enumType, // Receiver is the enum type + matchedConstructor + ); + + + callInfo.setConstructor(true); + callInfo.validate(); + return callInfo; + } + + /** + * Create EnumConstantInfo from a reflection-based enum constant. + * Used for enum constants from external libraries (via reflection). + * + * @param constantName The name of the enum constant + * @param enumType The enum type this constant belongs to + * @return EnumConstantInfo representing this constant, or null if not found + */ + public static EnumConstantInfo fromReflection(String constantName, TypeInfo enumType, Field javaField) { + if (enumType == null || enumType.getJavaClass() == null || !enumType.getJavaClass().isEnum()) { + return null; + } + + try { + FieldInfo fieldInfo = FieldInfo.enumConstant( + constantName, + enumType, + -1, // No declaration offset for reflection-based constants + -1, + -1, + javaField + ); + + EnumConstantInfo enumInfo = new EnumConstantInfo(fieldInfo, null, enumType); + fieldInfo.setEnumConstantInfo(enumInfo); + return enumInfo; + } catch (SecurityException e) { + // Constant not found or access denied + return null; + } + } + + /** + * Find where enum constants section ends. + * Returns the position of the first semicolon that's not inside parentheses, + * or the position of the first method/field declaration. + */ + private static int findEnumConstantsEnd(String bodyText) { + int parenDepth = 0; + boolean foundConstant = false; + + for (int i = 0; i < bodyText.length(); i++) { + char c = bodyText.charAt(i); + + if (c == '(') { + parenDepth++; + } else if (c == ')') { + parenDepth--; + } else if (c == ';' && parenDepth == 0) { + return i; + } else if (c == '{' && parenDepth == 0) { + // Start of method body - constants end before this + return findStatementStart(bodyText, i); + } + + // Track if we've found at least one identifier (potential constant) + if (Character.isJavaIdentifierStart(c)) { + foundConstant = true; + } + } + + // If we found constants but no semicolon, return the full length + return foundConstant ? bodyText.length() : -1; + } + + /** + * Find the start of a statement by going backwards from a position. + */ + private static int findStatementStart(String text, int fromPos) { + int pos = fromPos - 1; + int parenDepth = 0; + + while (pos >= 0) { + char c = text.charAt(pos); + if (c == ')') + parenDepth++; + else if (c == '(') + parenDepth--; + else if ((c == ',' || c == ';' || c == '{' || c == '}') && parenDepth == 0) { + return pos + 1; + } + pos--; + } + return 0; + } + + // ==================== GETTERS ==================== + + public FieldInfo getFieldInfo() { + return fieldInfo; + } + + public MethodCallInfo getConstructorCall() { + return constructorCall; + } + + public TypeInfo getEnumType() { + return enumType; + } + + public String getName() { + return fieldInfo.getName(); + } + + public int getDeclarationOffset() { + return fieldInfo.getDeclarationOffset(); + } + + /** + * Check if this enum constant has any errors (constructor mismatch, etc). + */ + public boolean hasError() { + return (constructorCall != null && constructorCall.hasError()); + } + + /** + * Get the error message if any. + */ + public String getErrorMessage() { + return null; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldAccessInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldAccessInfo.java new file mode 100644 index 000000000..fc87898f7 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldAccessInfo.java @@ -0,0 +1,152 @@ +package noppes.npcs.client.gui.util.script.interpreter.field; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +/** + * Metadata for field access validation. + * Parallel to MethodCallInfo but for field accesses like `obj.field` or `Class.staticField`. + */ +public class FieldAccessInfo { + + /** + * Validation error type for field accesses. + */ + public enum ErrorType { + NONE, + TYPE_MISMATCH, // Field type doesn't match expected type (e.g., assignment LHS) + UNRESOLVED_FIELD, // Field doesn't exist on the receiver type + STATIC_ACCESS_ERROR // Trying to access instance field statically or vice versa + } + + private final String fieldName; + private final int fieldNameStart; + private final int fieldNameEnd; + private final TypeInfo receiverType; // The type on which this field is accessed + private final FieldInfo resolvedField; // The resolved field (null if unresolved) + private final boolean isStaticAccess; // True if this is Class.field style access + private TypeInfo expectedType; // Expected field type (from assignment LHS, etc.) + + private ErrorType errorType = ErrorType.NONE; + private String errorMessage; + + public FieldAccessInfo(String fieldName, int fieldNameStart, int fieldNameEnd, + TypeInfo receiverType, FieldInfo resolvedField, boolean isStaticAccess) { + this.fieldName = fieldName; + this.fieldNameStart = fieldNameStart; + this.fieldNameEnd = fieldNameEnd; + this.receiverType = receiverType; + this.resolvedField = resolvedField; + this.isStaticAccess = isStaticAccess; + } + + // Getters + public String getFieldName() { + return fieldName; + } + + public int getFieldNameStart() { + return fieldNameStart; + } + + public int getFieldNameEnd() { + return fieldNameEnd; + } + + public TypeInfo getReceiverType() { + return receiverType; + } + + public FieldInfo getResolvedField() { + return resolvedField; + } + + public boolean isStaticAccess() { + return isStaticAccess; + } + + public boolean isEnumConstantAccess() { + return resolvedField != null && resolvedField.isEnumConstant(); + } + + public TypeInfo getExpectedType() { + return expectedType; + } + + public void setExpectedType(TypeInfo expectedType) { + this.expectedType = expectedType; + } + + public ErrorType getErrorType() { + return errorType; + } + + public String getErrorMessage() { + return errorMessage; + } + + /** + * Validate this field access. + * Checks type compatibility with expected type. + */ + public void validate() { + // Check if field was resolved + if (resolvedField == null) { + setError(ErrorType.UNRESOLVED_FIELD, "Cannot resolve field '" + fieldName + "'"); + return; + } + + // Check return type compatibility with expected type (e.g., assignment LHS) + if (expectedType != null && resolvedField != null) { + TypeInfo fieldType = resolvedField.getTypeInfo(); + if (fieldType != null && !TypeChecker.isTypeCompatible(expectedType, fieldType)) { + //extra space is necessary for alignment + //setError(ErrorType.TYPE_MISMATCH, "Provided type: " + fieldType.getSimpleName()+ + //"\nRequired: " + expectedType.getSimpleName()); + } + } + } + + private void setError(ErrorType type, String message) { + this.errorType = type; + this.errorMessage = message; + } + + /** + * Check if this field access has any validation error. + */ + public boolean hasError() { + return errorType != ErrorType.NONE; + } + + /** + * Check if this is a type mismatch error. + */ + public boolean hasTypeMismatch() { + return errorType == ErrorType.TYPE_MISMATCH; + } + + /** + * Check if this is an unresolved field error. + */ + public boolean hasUnresolvedField() { + return errorType == ErrorType.UNRESOLVED_FIELD; + } + + /** + * Check if this is a static access error. + */ + public boolean hasStaticAccessError() { + return errorType == ErrorType.STATIC_ACCESS_ERROR; + } + + @Override + public String toString() { + return "FieldAccessInfo{" + + "fieldName='" + fieldName + "', " + + "receiverType=" + receiverType + ", " + + "resolvedField=" + resolvedField + ", " + + "errorType=" + errorType + + '}'; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java new file mode 100644 index 000000000..8648d91f9 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java @@ -0,0 +1,597 @@ +package noppes.npcs.client.gui.util.script.interpreter.field; + +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSFieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.bridge.DtsJavaBridge; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.GenericContext; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeSubstitutor; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Metadata for a field (variable) declaration or reference. + * Tracks the field name, type, scope, and declaration location. + * Also tracks all assignments made to this field throughout the script. + */ +public final class FieldInfo { + + public enum Scope { + GLOBAL, // Class-level field + LOCAL, // Local variable inside a method + PARAMETER, // Method parameter + ENUM_CONSTANT // Enum constant value + } + + private final String name; + private final Scope scope; + private final TypeInfo declaredType; // The declared type (e.g., String, List) + private final int declarationOffset; // Where this field was declared in the source + private final boolean resolved; + private final String documentation; // Javadoc/comment documentation for this field + private JSDocInfo jsDocInfo; // Parsed JSDoc info for this method (may be null) + + // Initialization value range (for displaying "= value" in hover info) + private final int initStart; // Position of '=' or -1 if no initializer + private final int initEnd; // Position after initializer (before ';') or -1 + + // For local/parameter fields, track the containing method + private final MethodInfo containingMethod; + + // Modifiers for script-defined fields + private final int modifiers; // Java Modifier flags (e.g., Modifier.FINAL) + + // Reflection field for external types + private final Field reflectionField; + + // Assignments to this field (populated after parsing) + private final List assignments = new ArrayList<>(); + + // Declaration assignment (for initial value validation) + private AssignmentInfo declarationAssignment; + + // Enum constant specific info + private EnumConstantInfo enumConstantInfo; + + // Type inference support (for JavaScript "any" typed variables) + // When a variable is declared without type (var x;), it starts as "any" but can be + // refined through assignments or JSDoc comments + private TypeInfo inferredType; + + private FieldInfo(String name, Scope scope, TypeInfo declaredType, + int declarationOffset, boolean resolved, MethodInfo containingMethod, + String documentation, int initStart, int initEnd, int modifiers, + Field reflectionField) { + this.name = name; + this.scope = scope; + this.declaredType = declaredType; + this.declarationOffset = declarationOffset; + this.resolved = resolved; + this.containingMethod = containingMethod; + this.documentation = documentation; + this.initStart = initStart; + this.initEnd = initEnd; + this.modifiers = modifiers; + this.reflectionField = reflectionField; + } + + // Factory methods + public static FieldInfo globalField(String name, TypeInfo type, int declOffset) { + return new FieldInfo(name, Scope.GLOBAL, type, declOffset, type != null && type.isResolved(), null, null, -1, + -1, 0, null); + } + + public static FieldInfo globalField(String name, TypeInfo type, int declOffset, String documentation) { + return new FieldInfo(name, Scope.GLOBAL, type, declOffset, type != null && type.isResolved(), null, + documentation, -1, -1, 0, null); + } + + public static FieldInfo globalField(String name, TypeInfo type, int declOffset, String documentation, int initStart, int initEnd) { + return new FieldInfo(name, Scope.GLOBAL, type, declOffset, type != null && type.isResolved(), null, + documentation, initStart, initEnd, 0, null); + } + + public static FieldInfo globalField(String name, TypeInfo type, int declOffset, String documentation, int initStart, + int initEnd, int modifiers) { + return new FieldInfo(name, Scope.GLOBAL, type, declOffset, type != null && type.isResolved(), null, + documentation, initStart, initEnd, modifiers, null); + } + + public static FieldInfo localField(String name, TypeInfo type, int declOffset, MethodInfo method) { + return new FieldInfo(name, Scope.LOCAL, type, declOffset, type != null && type.isResolved(), method, null, -1, + -1, 0, null); + } + + public static FieldInfo localField(String name, TypeInfo type, int declOffset, MethodInfo method, int initStart, int initEnd) { + return new FieldInfo(name, Scope.LOCAL, type, declOffset, type != null && type.isResolved(), method, null, + initStart, initEnd, 0, null); + } + + public static FieldInfo localField(String name, TypeInfo type, int declOffset, MethodInfo method, int initStart, + int initEnd, int modifiers) { + return new FieldInfo(name, Scope.LOCAL, type, declOffset, type != null && type.isResolved(), method, null, + initStart, initEnd, modifiers, null); + } + + public static FieldInfo parameter(String name, TypeInfo type, int declOffset, MethodInfo method) { + return new FieldInfo(name, Scope.PARAMETER, type, declOffset, type != null && type.isResolved(), method, null, + -1, -1, 0, null); + } + + public static FieldInfo unresolved(String name, Scope scope) { + return new FieldInfo(name, scope, null, -1, false, null, null, -1, -1, 0, null); + } + + /** + * Create a FieldInfo from reflection data for method parameters. + */ + public static FieldInfo reflectionParam(String name, TypeInfo type) { + return new FieldInfo(name, Scope.PARAMETER, type, -1, true, null, null, -1, -1, 0, null); + } + + /** + * Create a FieldInfo for a synthetic/external field. + * Used for built-in types like Nashorn's Java object. + */ + public static FieldInfo external(String name, TypeInfo type, String documentation, int modifiers) { + return new FieldInfo(name, Scope.GLOBAL, type, -1, true, null, documentation, -1, -1, modifiers, null); + } + + /** + * Create a FieldInfo from reflection data for a class field. + * Preserves generic type information like List. + */ + public static FieldInfo fromReflection(Field field, TypeInfo containingType) { + String name = field.getName(); + // Use getGenericType() to preserve generic information + TypeInfo type = TypeInfo.fromGenericType(field.getGenericType()); + if (type == null) { + type = TypeInfo.fromClass(field.getType()); + } + + // If the receiver is parameterized, substitute class type variables in the field type + if (GenericContext.hasGenerics(containingType)) { + Map receiverBindings = TypeSubstitutor.createBindingsFromReceiver(containingType); + if (!receiverBindings.isEmpty()) { + type = TypeSubstitutor.substitute(type, receiverBindings); + } + } + + // Check if this is an enum constant + if (field.isEnumConstant()) { + // Create enum constant FieldInfo + EnumConstantInfo constantInfo = EnumConstantInfo.fromReflection(name, containingType,field); + if (constantInfo != null) + return constantInfo.getFieldInfo(); + } + + // Try to find matching JSFieldInfo to bridge over documentation + JSFieldInfo jsField = DtsJavaBridge.findMatchingField(field, containingType); + String documentation = null; + JSDocInfo jsDocInfo = null; + if (jsField != null) { + jsDocInfo = jsField.getJsDocInfo(); + String jsDocDesc = jsDocInfo != null ? jsDocInfo.getDescription() : null; + documentation = jsDocDesc != null ? jsDocDesc : jsField.getDocumentation(); + } + + FieldInfo fieldInfo = new FieldInfo(name, Scope.GLOBAL, type, -1, true, null, documentation, -1, -1, field.getModifiers(), field); + if (jsDocInfo != null) { + fieldInfo.setJSDocInfo(jsDocInfo); + } + return fieldInfo; + } + + /** + * Create a FieldInfo from a JSFieldInfo (parsed from .d.ts files). + * Used when resolving field access on JavaScript types. + * + * @param jsField The JavaScript field info from the type registry + * @param containingType The TypeInfo that owns this field + * @return A FieldInfo representing the JavaScript field + */ + public static FieldInfo fromJSField(JSFieldInfo jsField, TypeInfo containingType) { + String name = jsField.getName(); + + // Resolve the type from the JS type registry + TypeInfo type = resolveJSType(jsField.getType()); + + // JS fields are public by default, readonly maps to final + int modifiers = Modifier.PUBLIC; + if (jsField.isReadonly()) { + modifiers |= Modifier.FINAL; + } + + // Use documentation if available + JSDocInfo jsDocInfo = jsField.getJsDocInfo(); + String jsDocDesc = jsDocInfo != null ? jsDocInfo.getDescription() : null; + String documentation = jsDocDesc != null ? jsDocDesc : jsField.getDocumentation(); + + FieldInfo fieldInfo = new FieldInfo(name, Scope.GLOBAL, type, -1, true, null, documentation, -1, -1, modifiers, null); + fieldInfo.setJSDocInfo(jsDocInfo); + return fieldInfo; + } + + /** + * Resolves a JavaScript type name to a TypeInfo. + * Handles primitives, mapped types, and custom types from the registry. + */ + private static TypeInfo resolveJSType(String jsTypeName) { + if (jsTypeName == null || jsTypeName.isEmpty() || "void".equals(jsTypeName)) { + return TypeInfo.fromPrimitive("void"); + } + + // Handle JS primitives + switch (jsTypeName) { + case "string": + return TypeInfo.fromClass(String.class); + case "number": + return TypeInfo.fromClass(double.class); + case "boolean": + return TypeInfo.fromClass(boolean.class); + case "any": + return TypeInfo.fromClass(Object.class); + case "void": + return TypeInfo.fromPrimitive("void"); + } + + // Handle array types + if (jsTypeName.endsWith("[]")) { + String elementType = jsTypeName.substring(0, jsTypeName.length() - 2); + TypeInfo elementTypeInfo = resolveJSType(elementType); + return TypeInfo.arrayOf(elementTypeInfo); + } + + // Try to resolve from the JS type registry + JSTypeRegistry registry = JSTypeRegistry.getInstance(); + if (registry != null) { + JSTypeInfo jsTypeInfo = registry.getType(jsTypeName); + if (jsTypeInfo != null) { + return TypeInfo.fromJSTypeInfo(jsTypeInfo); + } + } + + // Fallback: unresolved type + return TypeInfo.unresolved(jsTypeName, jsTypeName); + } + + /** + * Create a FieldInfo for an enum constant. + * @param name The constant name (e.g., "NORTH") + * @param type The enum type itself + * @param declOffset The declaration position + * @param initStart The position of '(' if args present, else -1 + * @param initEnd The position after ')' if args present, else -1 + */ + public static FieldInfo enumConstant(String name, TypeInfo type, int declOffset, int initStart, int initEnd,Field javaField) { + // Enum constants are implicitly public static final + int modifiers = Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL; + return new FieldInfo(name, Scope.ENUM_CONSTANT, type, declOffset, true, null, null, initStart, initEnd, + modifiers, javaField); + } + + // ==================== ENUM HANDLING ==================== + + public void setEnumConstantInfo(EnumConstantInfo enumConstantInfo) { + this.enumConstantInfo = enumConstantInfo; + } + + public EnumConstantInfo getEnumInfo() { + return enumConstantInfo; + } + /** + * Check if this is an enum constant. + */ + public boolean isEnumConstant() { + return scope == Scope.ENUM_CONSTANT; + } + + + // ==================== ASSIGNMENT MANAGEMENT ==================== + + /** + * Add an assignment to this field. + */ + public void addAssignment(AssignmentInfo assignment) { + assignments.add(assignment); + } + + /** + * Get all assignments to this field (unmodifiable). + */ + public List getAssignments() { + return Collections.unmodifiableList(assignments); + } + + /** + * Set the declaration assignment (initial value assignment). + */ + public void setDeclarationAssignment(AssignmentInfo assignment) { + this.declarationAssignment = assignment; + } + + /** + * Get the declaration assignment (initial value assignment). + * Returns null if there was no initializer or it hasn't been validated yet. + */ + public AssignmentInfo getDeclarationAssignment() { + return declarationAssignment; + } + + /** + * Find an assignment that contains the given position. + * Returns null if no assignment contains this position. + * Prioritizes LHS matches over RHS matches. + */ + public AssignmentInfo findAssignmentAtPosition(int position) { + // Check declaration assignment first + if (declarationAssignment != null && declarationAssignment.containsPosition(position)) { + // Prioritize LHS match + if (declarationAssignment.containsLhsPosition(position)) { + return declarationAssignment; + } + } + + // Check other assignments for LHS matches first (more specific) + for (AssignmentInfo assign : assignments) { + if (assign.containsLhsPosition(position)) { + return assign; + } + } + + // Then check RHS matches (less specific - might just be a reference in the value) + if (declarationAssignment != null && declarationAssignment.containsRhsPosition(position)) { + return declarationAssignment; + } + + for (AssignmentInfo assign : assignments) { + if (assign.containsRhsPosition(position)) { + return assign; + } + } + + return null; + } + + /** + * Get all errored assignments for this field. + */ + public List getErroredAssignments() { + List errored = new ArrayList<>(); + + // Include declaration assignment if it has an error + if (declarationAssignment != null && declarationAssignment.hasError()) { + errored.add(declarationAssignment); + } + + // Include all other assignments with errors + for (AssignmentInfo assign : assignments) { + if (assign.hasError()) { + errored.add(assign); + } + } + return errored; + } + + /** + * Clear all assignments (for re-parsing). + */ + public void clearAssignments() { + assignments.clear(); + declarationAssignment = null; + } + + // ==================== MODIFIER CHECKS ==================== + + /** + * Check if this field is declared as final. + * Works for both script-defined fields (via modifiers) and reflection fields. + */ + public boolean isFinal() { + if (reflectionField != null) + return Modifier.isFinal(reflectionField.getModifiers()); + + return Modifier.isFinal(modifiers); + } + + /** + * Check if this field is declared as static. + */ + public boolean isStatic() { + if (reflectionField != null) + return Modifier.isStatic(reflectionField.getModifiers()); + + return Modifier.isStatic(modifiers); + } + + /** + * Check if this field is declared as private. + */ + public boolean isPrivate() { + if (reflectionField != null) + return Modifier.isPrivate(reflectionField.getModifiers()); + + return Modifier.isPrivate(modifiers); + } + + /** + * Check if this field is declared as protected. + */ + public boolean isProtected() { + if (reflectionField != null) + return Modifier.isProtected(reflectionField.getModifiers()); + + return Modifier.isProtected(modifiers); + } + + /** + * Check if this field is declared as public. + */ + public boolean isPublic() { + if (reflectionField != null) + return Modifier.isPublic(reflectionField.getModifiers()); + + return Modifier.isPublic(modifiers); + } + + /** + * Get the raw modifiers value. + */ + public int getModifiers() { + if (reflectionField != null) + return reflectionField.getModifiers(); + + return modifiers; + } + + /** + * Get the reflection field, if available. + */ + public java.lang.reflect.Field getReflectionField() { + return reflectionField; + } + + // ==================== TYPE INFERENCE ==================== + + /** + * Get the inferred type for this field. + * This is set when a variable declared as "any" has its type refined through + * assignment analysis or JSDoc comments. + */ + public TypeInfo getInferredType() { + return inferredType; + } + + /** + * Set the inferred type for this field. + * Used when type inference determines a more specific type than the declared type. + * @param inferredType The refined type based on assignments or JSDoc + */ + public void setInferredType(TypeInfo inferredType) { + this.inferredType = inferredType; + } + + /** + * Check if this field has a type that can be refined (currently "any" type). + */ + public boolean canInferType() { + return declaredType != null && "any".equals(declaredType.getFullName()); + } + + /** + * Get the effective type for this field, considering inference. + * Returns inferredType if available, otherwise declaredType. + */ + public TypeInfo getEffectiveType() { + if (inferredType != null) { + return inferredType; + } + return declaredType; + } + + // ==================== BASIC GETTERS ==================== + + public String getName() { return name; } + public Scope getScope() { return scope; } + public TypeInfo getDeclaredType() { return declaredType; } + public TypeInfo getTypeInfo() { + // Return the effective type (inferred if available, otherwise declared) + return getEffectiveType(); + } + public int getDeclarationOffset() { return declarationOffset; } + public boolean isResolved() { + // Check if we have any resolved type (declared or inferred) + TypeInfo effectiveType = getEffectiveType(); + return effectiveType != null && effectiveType.isResolved(); + } + public MethodInfo getContainingMethod() { return containingMethod; } + public String getDocumentation() { return documentation; } + public JSDocInfo getJSDocInfo() { return jsDocInfo; } + public void setJSDocInfo(JSDocInfo jsDocInfo) { this.jsDocInfo = jsDocInfo; } + public int getInitStart() { return initStart; } + public int getInitEnd() { return initEnd; } + public boolean hasInitializer() { return initStart >= 0 && initEnd > initStart; } + + public boolean isGlobal() { return scope == Scope.GLOBAL; } + public boolean isLocal() { return scope == Scope.LOCAL; } + public boolean isParameter() { return scope == Scope.PARAMETER; } + + /** + * Check if a reference at the given position can see this field. + * For local variables, they're only visible after their declaration. + */ + public boolean isVisibleAt(int position) { + if (scope == Scope.GLOBAL || scope == Scope.PARAMETER) { + return true; // Always visible in their scope + } + // Local variables are only visible after declaration + return position >= declarationOffset; + } + + /** + * Get the appropriate TokenType for highlighting this field. + */ + public TokenType getTokenType() { + if (!resolved) { + return TokenType.UNDEFINED_VAR; + } + switch (scope) { + case GLOBAL: + // Static final fields get special highlighting + if (isStatic() && isFinal()) { + return TokenType.STATIC_FINAL_FIELD; + } + return TokenType.GLOBAL_FIELD; + case LOCAL: + return TokenType.LOCAL_FIELD; + case PARAMETER: + return TokenType.PARAMETER; + case ENUM_CONSTANT: + return TokenType.ENUM_CONSTANT; + default: + return TokenType.VARIABLE; + } + } + + @Override + public String toString() { + return "FieldInfo{" + name + ", " + scope + ", type=" + declaredType + ", final=" + isFinal() + "}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FieldInfo fieldInfo = (FieldInfo) o; + return name.equals(fieldInfo.name) && scope == fieldInfo.scope; + } + + @Override + public int hashCode() { + return name.hashCode() * 31 + scope.ordinal(); + } + + /** + * Wrapper class that holds both FieldInfo and MethodCallInfo for a variable usage. + * This is used when a variable is used as an argument to a method call. + */ + public static class ArgInfo { + public final FieldInfo fieldInfo; + public final MethodCallInfo methodCallInfo; + + public ArgInfo(FieldInfo fieldInfo, MethodCallInfo methodCallInfo) { + this.fieldInfo = fieldInfo; + this.methodCallInfo = methodCallInfo; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java new file mode 100644 index 000000000..3c5087533 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java @@ -0,0 +1,304 @@ +package noppes.npcs.client.gui.util.script.interpreter.hover; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.util.ResourceLocation; +import noppes.npcs.client.ClientProxy; +import noppes.npcs.client.gui.util.GuiUtil; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * Renders gutter icons for method inheritance (override/implements) in the script editor. + * + * Renders IntelliJ-style tooltips when hovering over gutter icons: + * - Background + border matching TokenHoverRenderer style + * - Colored declaration showing which class/interface the method overrides/implements + * - Uses the same token highlighting system for type names + */ +public class GutterIconRenderer { + + // ==================== CONSTANTS ==================== + + /** Texture resource for script icons (64x32: first 32x32 = override, second 32x32 = implements) */ + private static final ResourceLocation SCRIPT_ICONS = new ResourceLocation("customnpcs", + "textures/gui/script/icons.png"); + + /** Icon size when rendered in the gutter (scaled from 32x32) */ + private static final int GUTTER_ICON_SIZE = 10; + + /** Extra gutter width to accommodate inheritance icons */ + public static final int ICON_GUTTER_WIDTH = 12; + + /** Padding inside the tooltip box */ + private static final int PADDING = 6; + + /** Line spacing between rows */ + private static final int LINE_SPACING = 2; + + /** Vertical offset from mouse */ + private static final int VERTICAL_OFFSET = 10; + + // ==================== COLORS ==================== + + /** Background color (dark gray like IntelliJ) */ + private static final int BG_COLOR = 0xF0313335; + + /** Border color */ + private static final int BORDER_COLOR = 0xFF3C3F41; + + /** Info text color */ + private static final int INFO_COLOR = 0xFFA9B7C6; + + // ==================== RENDERING ==================== + + /** + * Render gutter icons for a range of lines. + * + * @param lineHeight Height of each line + * @param gutterX X position for the icon in the gutter + * @param gutterY Base Y position of the gutter + * @param renderStart First line index to render + * @param renderEnd Last line index to render + * @param scrolledLine Current scroll position + * @param stringYOffset Y offset for text positioning + * @param methods All methods in the document + * @param lines Line data for position calculation + * @param xMouse Mouse X position + * @param yMouse Mouse Y position + * @param fracPixels Fractional scroll offset + * @return The hovered method, or null if no icon is hovered + */ + public static MethodInfo renderIcons( + int lineHeight, + int gutterX, + int gutterY, + int renderStart, + int renderEnd, + int scrolledLine, + int stringYOffset, + List methods, + List lines, + int xMouse, + int yMouse, + float fracPixels) { + + if (methods == null || methods.isEmpty()) + return null; + + MethodInfo hoveredMethod = null; + float adjustedMouseY = yMouse + fracPixels; + + for (int lineIndex = renderStart; lineIndex <= renderEnd; lineIndex++) { + MethodInfo method = getMethodAtLine(lineIndex, methods, lines); + if (method == null || !method.hasInheritanceMarker()) + continue; + + int posY = gutterY + (lineIndex - scrolledLine) * lineHeight + stringYOffset; + int iconY = posY + (lineHeight - GUTTER_ICON_SIZE) / 2 - 1; + + // Draw the icon + renderIcon(gutterX, iconY, method.isOverride()); + + // Check for hover + int iconScaleOffsetX = -4; + int screenPosY = gutterY + (lineIndex - scrolledLine) * lineHeight; + if (xMouse >= gutterX + iconScaleOffsetX && xMouse < gutterX + GUTTER_ICON_SIZE + iconScaleOffsetX && + adjustedMouseY >= screenPosY && adjustedMouseY < screenPosY + lineHeight) { + hoveredMethod = method; + } + } + + return hoveredMethod; + } + + /** + * Render a single gutter icon. + */ + private static void renderIcon(int x, int y, boolean isOverride) { + int iconU = isOverride ? 0 : 32; + + Minecraft.getMinecraft().renderEngine.bindTexture(SCRIPT_ICONS); + GL11.glColor4f(1.0f, 1.0f, 1.0f, 1.0f); + + GL11.glPushMatrix(); + float scale = 2.0f, scaleOffsetX = -4, scaleOffsetY = -3.25f; + GL11.glScalef(scale, scale, scale); + GL11.glTranslatef(scaleOffsetX, scaleOffsetY, 0); + GuiUtil.drawScaledTexturedRect( + (int) (x / scale), (int) (y / scale), + iconU, 0, 32, 32, + GUTTER_ICON_SIZE, GUTTER_ICON_SIZE, + 64, 32 + ); + GL11.glPopMatrix(); + } + + /** + * Render the tooltip for a hovered gutter icon. + */ + public static void renderTooltip(MethodInfo method, int mouseX, int mouseY, int viewportX, int viewportWidth, + int viewportY, int viewportHeight) { + if (method == null) + return; + + // Build tooltip content + List segments = buildTooltipContent(method); + if (segments.isEmpty()) + return; + + int lineHeight = ClientProxy.Font.height(); + + // Calculate dimensions + int contentWidth = calculateContentWidth(segments); + int contentHeight = lineHeight; + int boxWidth = contentWidth + PADDING * 2; + int boxHeight = contentHeight + PADDING * 2; + + // Position tooltip + int tooltipX = mouseX + VERTICAL_OFFSET; + int tooltipY = mouseY - 5; + + // Clamp to viewport + int rightBound = viewportX + viewportWidth; + int bottomBound = viewportY + viewportHeight; + + if (tooltipX + boxWidth > rightBound) { + tooltipX = mouseX - boxWidth - 5; + } + if (tooltipX < viewportX) { + tooltipX = viewportX; + } + + if (tooltipY + boxHeight > bottomBound) { + tooltipY = bottomBound - boxHeight; + } + if (tooltipY < viewportY) { + tooltipY = viewportY; + } + + // Render tooltip box + renderTooltipBox(tooltipX, tooltipY, boxWidth, boxHeight, segments); + } + + // ==================== PRIVATE HELPERS ==================== + + /** + * Get the method that starts on the given line. + */ + private static MethodInfo getMethodAtLine(int lineIndex, List methods, List lines) { + if (lineIndex < 0 || lineIndex >= lines.size()) + return null; + + Object lineObj = lines.get(lineIndex); + int lineStart, lineEnd; + + try { + java.lang.reflect.Field startField = lineObj.getClass().getField("start"); + java.lang.reflect.Field endField = lineObj.getClass().getField("end"); + lineStart = startField.getInt(lineObj); + lineEnd = endField.getInt(lineObj); + } catch (Exception e) { + return null; + } + + for (MethodInfo method : methods) { + if (!method.hasInheritanceMarker()) + continue; + int nameOffset = method.getNameOffset(); + if (nameOffset >= lineStart && nameOffset < lineEnd) { + return method; + } + } + + return null; + } + + /** + * Build the tooltip content as colored text segments. + */ + private static List buildTooltipContent(MethodInfo method) { + List segments = new ArrayList<>(); + + if (method.isOverride()) { + TypeInfo overridesFrom = method.getOverridesFrom(); + segments.add(new TextSegment("Overrides method in ", INFO_COLOR)); + + if (overridesFrom != null) { + int color = TokenType.getColor(overridesFrom); + segments.add(new TextSegment(overridesFrom.getSimpleName(), color)); + } else { + segments.add(new TextSegment("parent class", INFO_COLOR)); + } + } else if (method.isImplements()) { + TypeInfo implementsFrom = method.getImplementsFrom(); + segments.add(new TextSegment("Implements method from ", INFO_COLOR)); + + if (implementsFrom != null) { + int color = TokenType.getColor(implementsFrom); + segments.add(new TextSegment(implementsFrom.getSimpleName(), color)); + } else { + segments.add(new TextSegment("interface", INFO_COLOR)); + } + } + + return segments; + } + + /** + * Calculate the width needed for the segments. + */ + private static int calculateContentWidth(List segments) { + int totalWidth = 0; + for (TextSegment segment : segments) { + totalWidth += ClientProxy.Font.width(segment.text); + } + return totalWidth; + } + + /** + * Render the tooltip box with background, border, and content. + */ + private static void renderTooltipBox(int x, int y, int width, int height, List segments) { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + + // Draw background + int paddedHeight = y + height - 4; + Gui.drawRect(x, y, x + width, paddedHeight, BG_COLOR); + + // Draw border + Gui.drawRect(x, y, x + width, y + 1, BORDER_COLOR); // Top + Gui.drawRect(x, paddedHeight - 1, x + width, paddedHeight, BORDER_COLOR); // Bottom + Gui.drawRect(x, y, x + 1, paddedHeight, BORDER_COLOR); // Left + Gui.drawRect(x + width - 1, y, x + width, paddedHeight, BORDER_COLOR); // Right + + // Draw text segments + int currentX = x + PADDING; + int currentY = y + PADDING; + + for (TextSegment segment : segments) { + ClientProxy.Font.drawString(segment.text, currentX, currentY, 0xFF000000 | segment.color); + currentX += ClientProxy.Font.width(segment.text); + } + } + + // ==================== DATA CLASSES ==================== + + /** + * A colored text segment. + */ + private static class TextSegment { + final String text; + final int color; + + TextSegment(String text, int color) { + this.text = text; + this.color = color; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java new file mode 100644 index 000000000..9a0afe286 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java @@ -0,0 +1,335 @@ +package noppes.npcs.client.gui.util.script.interpreter.hover; + +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.ScriptLine; +import noppes.npcs.client.gui.util.script.interpreter.token.Token; + +/** + * Manages the hover state for token tooltips in the script editor. + * + * Tracks: + * - Current mouse position + * - Hovered token (if any) + * - Hover duration for delayed tooltip display + * - Whether tooltip should be visible + * + * Implements a 500ms delay before showing tooltips, resetting when + * the mouse moves to a different token. + */ +public class HoverState { + + /** Minimum hover time (ms) before showing tooltip */ + private static final long HOVER_DELAY_MS = 100; + + // ==================== STATE ==================== + + /** Currently hovered token (null if none) */ + private Token hoveredToken; + + /** Time when hover on current token started */ + private long hoverStartTime; + + /** Whether the tooltip should currently be displayed */ + private boolean tooltipVisible; + + /** Cached hover info for the current token */ + private TokenHoverInfo hoverInfo; + + /** Last known mouse position */ + private int lastMouseX; + private int lastMouseY; + + /** Position of the hovered token (for tooltip positioning) */ + private int tokenScreenX; + private int tokenScreenY; + private int tokenWidth; + + /** Pinned token (set when user clicks a token to keep tooltip open) */ + private Token pinnedToken; + private TokenHoverInfo pinnedHoverInfo; + + /** Whether click-to-pin behaviour is enabled for this hover state. */ + private boolean clickToPinEnabled = true; + + // ==================== UPDATE ==================== + + /** + * Update the hover state based on current mouse position. + * Should be called every frame from drawTextBox. + * + * @param mouseX Current mouse X position + * @param mouseY Current mouse Y position + * @param document The script document + * @param viewportX X position of the text viewport (after gutter) + * @param viewportY Y position of the text viewport + * @param viewportWidth Width of the text viewport + * @param viewportHeight Height of the text viewport + * @param scrollOffset Current vertical scroll offset (in lines) + * @param lineHeight Height of each line in pixels + * @param gutterWidth Width of the line number gutter + */ + public void update(int mouseX, int mouseY, ScriptDocument document, + int viewportX, int viewportY, int viewportWidth, int viewportHeight, + float scrollOffset, int lineHeight, int gutterWidth) { + + lastMouseX = mouseX; + lastMouseY = mouseY; + + // Check if mouse is within the text viewport + if (mouseX < viewportX || mouseX > viewportX + viewportWidth || + mouseY < viewportY || mouseY > viewportY + viewportHeight) { + clearHover(); + return; + } + + if (document == null) { + clearHover(); + return; + } + + // Calculate which line the mouse is over + int relativeY = mouseY - viewportY; + int lineIndex = (int) (scrollOffset + (relativeY / (float) lineHeight)); + + // Get the line + ScriptLine line = null; + for (ScriptLine l : document.getLines()) { + if (l.getLineIndex() == lineIndex) { + line = l; + break; + } + } + + if (line == null) { + clearHover(); + return; + } + + // Calculate character position within the line + int relativeX = mouseX - viewportX; + int globalPos = line.getGlobalStart() + getCharacterIndexAtX(line, relativeX); + + // Find the token at this position + Token token = line.getTokenAt(globalPos); + + if (token == null) { + clearHover(); + return; + } + + // Check if this is a new token + if (token != hoveredToken) { + // New token - reset timer + hoveredToken = token; + hoverStartTime = System.currentTimeMillis(); + tooltipVisible = false; + hoverInfo = null; + + // Calculate token screen position for tooltip + calculateTokenPosition(line, token, viewportX, viewportY, scrollOffset, lineHeight); + } else { + // Same token - check if delay has elapsed + long elapsed = System.currentTimeMillis() - hoverStartTime; + if (elapsed >= HOVER_DELAY_MS && !tooltipVisible) { + tooltipVisible = true; + hoverInfo = TokenHoverInfo.fromToken(token); + } + } + } + + /** + * Update the hover state with a specific token. + * Simplified version for when the token has already been found. + * + * @param mouseX Current mouse X position + * @param mouseY Current mouse Y position + * @param token The token at the mouse position (or null if none) + * @param tokenX Screen X position of the token + * @param tokenY Screen Y position of the token + * @param tokenW Width of the token in pixels + */ + public void update(int mouseX, int mouseY, Token token, int tokenX, int tokenY, int tokenW) { + lastMouseX = mouseX; + lastMouseY = mouseY; + clickToPinEnabled=false; + // If a token has been pinned by click, ignore mouse movement updates + if (pinnedToken != null) { + // keep pinned tooltip visible + tooltipVisible = true; + hoverInfo = pinnedHoverInfo; + return; + } + + if (token == null) { + clearHover(); + return; + } + + // Check if this is a new token + if (token != hoveredToken) { + // New token - reset timer + hoveredToken = token; + hoverStartTime = System.currentTimeMillis(); + tooltipVisible = false; + hoverInfo = null; + + // Store token position + tokenScreenX = tokenX; + tokenScreenY = tokenY; + tokenWidth = tokenW; + } else { + // Same token - check if delay has elapsed + long elapsed = System.currentTimeMillis() - hoverStartTime; + if (elapsed >= HOVER_DELAY_MS && !tooltipVisible) { + tooltipVisible = true; + hoverInfo = TokenHoverInfo.fromToken(token); + } + } + } + + /** + * Clear the current hover state. + */ + public void clearHover() { + if (hoveredToken != null) { + hoveredToken = null; + hoverStartTime = 0; + // Do not clear tooltipInfo here if pinned; if not pinned, hide tooltip. + if (pinnedToken == null) { + tooltipVisible = false; + hoverInfo = null; + } + } + } + + /** + * Force the tooltip to hide (e.g., when clicking). + */ + public void hideTooltip() { + tooltipVisible = false; + } + + /** + * Enable or disable click-to-pin behaviour. + */ + public void setClickToPinEnabled(boolean enabled) { + this.clickToPinEnabled = enabled; + } + + public boolean isClickToPinEnabled() { + return clickToPinEnabled; + } + + /** + * Pin a token so its tooltip stays visible until explicitly unpinned. + */ + public void pinToken(Token token, int tokenX, int tokenY, int tokenW) { + if (token == null) return; + this.pinnedToken = token; + this.pinnedHoverInfo = TokenHoverInfo.fromToken(token); + this.tooltipVisible = pinnedHoverInfo != null && pinnedHoverInfo.hasContent(); + this.tokenScreenX = tokenX; + this.tokenScreenY = tokenY; + this.tokenWidth = tokenW; + // ensure hoveredToken reflects pinned token + this.hoveredToken = token; + } + + /** + * Unpin any pinned token and hide tooltip. + */ + public void unpin() { + this.pinnedToken = null; + this.pinnedHoverInfo = null; + this.tooltipVisible = false; + this.hoverInfo = null; + } + + public boolean isPinned() { return pinnedToken != null; } + + // ==================== POSITION CALCULATION ==================== + + /** + * Get the character index within a line at the given X pixel position. + */ + private int getCharacterIndexAtX(ScriptLine line, int x) { + String text = line.getText(); + if (text == null || text.isEmpty()) return 0; + + int accumWidth = 0; + for (int i = 0; i < text.length(); i++) { + int charWidth = noppes.npcs.client.ClientProxy.Font.width(String.valueOf(text.charAt(i))); + if (accumWidth + charWidth / 2 > x) { + return i; + } + accumWidth += charWidth; + } + return text.length(); + } + + /** + * Calculate the screen position of a token for tooltip positioning. + */ + private void calculateTokenPosition(ScriptLine line, Token token, + int viewportX, int viewportY, + float scrollOffset, int lineHeight) { + // X position: calculate width of text before the token + String lineText = line.getText(); + int tokenLocalStart = token.getGlobalStart() - line.getGlobalStart(); + tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length())); + + String textBefore = lineText.substring(0, tokenLocalStart); + tokenScreenX = viewportX + noppes.npcs.client.ClientProxy.Font.width(textBefore); + + // Y position: line position minus scroll + int lineY = line.getLineIndex(); + tokenScreenY = viewportY + (int) ((lineY - scrollOffset) * lineHeight); + + // Token width + tokenWidth = noppes.npcs.client.ClientProxy.Font.width(token.getText()); + } + + // ==================== GETTERS ==================== + + public boolean isTooltipVisible() { + return tooltipVisible && hoverInfo != null && hoverInfo.hasContent(); + } + + public TokenHoverInfo getHoverInfo() { + return hoverInfo; + } + + public Token getHoveredToken() { + return hoveredToken; + } + + public int getTokenScreenX() { + return tokenScreenX; + } + + public int getTokenScreenY() { + return tokenScreenY; + } + + public int getTokenWidth() { + return tokenWidth; + } + + public int getLastMouseX() { + return lastMouseX; + } + + public int getLastMouseY() { + return lastMouseY; + } + + /** + * Get the progress (0.0 to 1.0) of the hover delay. + * Can be used for fade-in animation. + */ + public float getHoverProgress() { + if (hoveredToken == null) return 0f; + long elapsed = System.currentTimeMillis() - hoverStartTime; + return Math.min(1.0f, elapsed / (float) HOVER_DELAY_MS); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java new file mode 100644 index 000000000..137c4db02 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java @@ -0,0 +1,1569 @@ +package noppes.npcs.client.gui.util.script.interpreter.hover; + +import noppes.npcs.client.gui.util.script.interpreter.*; +import noppes.npcs.client.gui.util.script.interpreter.field.AssignmentInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocParamTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocReturnTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocSeeTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocTypeTag; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.Token; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenErrorMessage; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +/** + * Data class containing all displayable information for a token hover tooltip. + * Extracts and formats information from Token metadata for rendering. + * + * Supports display for: + * - Classes/Interfaces/Enums: Package, declaration with modifiers/extends/implements + * - Methods: Return type, signature, parameters, Javadoc + * - Fields: Type, containing class, declaration + * - Variables: Type, scope (local/parameter/global) + * - Errors: Validation errors, type mismatches + */ +public class TokenHoverInfo { + + // ==================== DISPLAY SECTIONS ==================== + + /** Package name (e.g., "net.minecraft.client") - shown in gray */ + private String packageName; + + /** Icon indicator (e.g., "C" for class, "I" for interface, "m" for method) */ + private String iconIndicator; + + /** Primary declaration line with syntax coloring */ + private List declaration = new ArrayList<>(); + + /** Javadoc/documentation comment lines */ + private List documentation = new ArrayList<>(); + + /** JSDoc-formatted documentation with colored segments (sections like Params, Returns) */ + private List jsDocLines = new ArrayList<>(); + + /** Error messages (shown in red) */ + private List errors = new ArrayList<>(); + + /** Additional info lines (e.g., "Variable 'x' is never used") */ + private List additionalInfo = new ArrayList<>(); + + /** The token this info was built from */ + private final Token token; + + // ==================== DOCUMENTATION LINE ==================== + + /** + * A line of documentation that may contain colored segments. + * Used for JSDoc-style rendering with "Params:", parameter names, etc. + */ + public static class DocumentationLine { + public final List segments; + + public DocumentationLine() { + this.segments = new ArrayList<>(); + } + + public void addSegment(String text, int color) { + segments.add(new TextSegment(text, color)); + } + + public void addText(String text) { + segments.add(new TextSegment(text, TextSegment.COLOR_DEFAULT)); + } + + public boolean isEmpty() { + return segments.isEmpty() || segments.stream().allMatch(s -> s.text == null || s.text.isEmpty()); + } + } + + // ==================== TEXT SEGMENT ==================== + + /** + * A colored segment of text within the declaration line. + */ + public static class TextSegment { + public final String text; + public final int color; + + public TextSegment(String text, int color) { + this.text = text; + this.color = color; + } + + // Predefined colors matching IntelliJ dark theme + public static final int COLOR_KEYWORD = 0xCC7832; // Orange for keywords/modifiers + public static final int COLOR_TYPE = 0x6897BB; // Blue for types + public static final int COLOR_CLASS = 0xA9B7C6; // Light gray for class names + public static final int COLOR_METHOD = 0xFFC66D; // Yellow for method names + public static final int COLOR_FIELD = 0x9876AA; // Purple for fields + public static final int COLOR_PARAM = 0xA9B7C6; // Light gray for parameters + public static final int COLOR_PACKAGE = 0x808080; // Gray for package + public static final int COLOR_DEFAULT = 0xA9B7C6; // Default text + public static final int COLOR_ERROR = 0xFF6B68; // Red for errors + public static final int COLOR_STRING = 0x6A8759; // Green for strings + public static final int COLOR_ANNOTATION = 0xBBB529; // Yellow-green for annotations + } + + // ==================== CONSTRUCTOR ==================== + + private TokenHoverInfo(Token token) { + this.token = token; + } + + // ==================== FACTORY METHOD ==================== + + /** + * Build hover info from a token based on its type and metadata. + */ + public static TokenHoverInfo fromToken(Token token) { + if (token == null) return null; + + TokenHoverInfo info = new TokenHoverInfo(token); + + // First, check for errors + info.extractErrors(token); + + // Then, extract type-specific information + switch (token.getType()) { + case IMPORTED_CLASS: + case CLASS_DECL: + case INTERFACE_DECL: + case ENUM_DECL: + case TYPE_DECL: + info.extractClassInfo(token); + break; + + case METHOD_CALL: + info.extractMethodCallInfo(token); + break; + + case METHOD_DECL: + info.extractMethodDeclInfo(token); + break; + + case ENUM_CONSTANT: + info.extractEnumConstantInfo(token); + break; + case GLOBAL_FIELD: + info.extractGlobalFieldInfo(token); + break; + + case LOCAL_FIELD: + info.extractLocalFieldInfo(token); + break; + + case PARAMETER: + info.extractParameterInfo(token); + break; + + case UNDEFINED_VAR: + info.extractUndefinedInfo(token); + break; + case LITERAL: + case KEYWORD: + case MODIFIER: + case STRING: + case COMMENT: + if (info.hasErrors()) + return info; + return null; + default: + // For other types, try to extract any available metadata + if (token.getTypeInfo() != null) { + info.extractClassInfo(token); + } else if (token.getMethodInfo() != null) { + info.extractMethodDeclInfo(token); + } else if (token.getFieldInfo() != null) { + info.extractFieldInfoGeneric(token); + } else if (info.hasErrors()) + return info; + else { + return null; // Nothing to show + } + break; + } + + return info; + } + + // ==================== EXTRACTION METHODS ==================== + + private void extractErrors(Token token) { + // Check if this token is part of a method call argument (positional lookup) + MethodCallInfo containingCall = findMethodCallContainingPosition(token); + if (containingCall != null) { + MethodCallInfo.Argument containingArg = findArgumentContainingPosition(containingCall, token.getGlobalStart()); + if (containingArg != null) { + // Show only this argument's specific error + MethodCallInfo.ArgumentTypeError argError = findArgumentError(containingCall, containingArg); + if (argError != null) { + errors.add(argError.getMessage()); + return; // Only show argument error, not method-level errors + } + } + } + + // Show method-level errors if this is the method name itself + MethodCallInfo callInfo = token.isEnumConstant()? containingCall : token.getMethodCallInfo(); + if (callInfo != null) { + if (callInfo.hasArgCountError()) { + errors.add(callInfo.getErrorMessage()); + } + if (callInfo.hasArgTypeError()) { + for (MethodCallInfo.ArgumentTypeError error : callInfo.getArgumentTypeErrors()) { + errors.add(error.getMessage()); + } + } + if (callInfo.hasReturnTypeMismatch()) { + errors.add(callInfo.getErrorMessage()); + } + if (callInfo.hasStaticAccessError()) { + errors.add(callInfo.getErrorMessage()); + } + } + + // Show field access errors + FieldAccessInfo fieldAccessInfo = token.getFieldAccessInfo(); + if (fieldAccessInfo != null && fieldAccessInfo.hasError()) { + errors.add(fieldAccessInfo.getErrorMessage()); + } + + // Show assignment errors (type mismatch, final reassignment, etc.) + // Search through FieldInfo's assignments by position + AssignmentInfo assignmentInfo = findAssignmentContainingPosition(token); + if (assignmentInfo != null && assignmentInfo.hasError()) { + errors.add(assignmentInfo.getErrorMessage()); + } + + // Show unresolved field errors + FieldInfo fieldInfo = token.getFieldInfo(); + if (fieldInfo != null && !fieldInfo.isResolved()) { + errors.add("Cannot resolve symbol '" + token.getText() + "'"); + } + + // Show method declaration errors (missing return, parameter errors, return type errors) + MethodInfo methodDecl = findMethodDeclarationContainingPosition(token); + if (methodDecl != null && methodDecl.hasError()) { + int tokenStart = token.getGlobalStart(); + int tokenEnd = token.getGlobalEnd(); + + // If hovering over the method name, show missing return error + if (methodDecl.hasMissingReturnError()) { + int methodNameStart = methodDecl.getNameOffset(); + int methodNameEnd = methodNameStart + methodDecl.getName().length(); + + if (tokenStart >= methodNameStart && tokenEnd <= methodNameEnd) { + errors.add(methodDecl.getErrorMessage()); + } + } + + // If hovering over a parameter with an error, show that error + else if (methodDecl.hasParameterErrors()) { + for (MethodInfo.ParameterError paramError : methodDecl.getParameterErrors()) { + FieldInfo param = paramError.getParameter(); + if (param != null && param.getDeclarationOffset() >= 0) { + int paramStart = param.getDeclarationOffset(); + int paramEnd = paramStart + param.getName().length(); + + if (tokenStart >= paramStart && tokenEnd <= paramEnd) { + errors.add(paramError.getMessage()); + } + } + } + } + + // If hovering over a return statement with a type error, show that error + else if (methodDecl.hasReturnStatementErrors()) { + for (MethodInfo.ReturnStatementError returnError : methodDecl.getReturnStatementErrors()) { + int returnStart = returnError.getStartOffset(); + int returnEnd = returnError.getEndOffset(); + + if (tokenStart >= returnStart && tokenEnd <= returnEnd) { + errors.add(returnError.getMessage()); + } + } + } + + + // All other errors + else if (methodDecl.hasError()) { + int declStart = methodDecl.getFullDeclarationOffset(); + int declEnd = methodDecl.getDeclarationEnd(); + + if (tokenStart >= declStart && tokenEnd <= declEnd) { + errors.add(methodDecl.getErrorMessage()); + } + } + } + + ScriptTypeInfo scriptType = findScriptTypeContainingPosition(token); + if (scriptType != null && scriptType.hasError()) { + // Missing interface method errors + for (ScriptTypeInfo.MissingMethodError err : scriptType.getMissingMethodErrors()) { + errors.add(err.getMessage()); + } + // Constructor mismatch errors + for (ScriptTypeInfo.ConstructorMismatchError err : scriptType.getConstructorMismatchErrors()) { + errors.add(err.getMessage()); + } + // General error message + if (scriptType.getErrorMessage() != null) { + errors.add(scriptType.getErrorMessage()); + } + } + + EnumConstantInfo enumConst = findEnumConstantContainingPosition(token); + if (enumConst != null && enumConst.hasError()) { + errors.add(enumConst.getErrorMessage()); + } + + if(token.getType() == TokenType.UNDEFINED_VAR) + errors.add("Cannot resolve symbol '" + token.getText() + "'"); + + TokenErrorMessage msg = token.getErrorMessage(); + if (msg != null && !msg.getMessage().isEmpty()) { + if(msg.clearOtherErrors) + errors.clear(); + + errors.add(msg.getMessage()); + } + } + + /** + * Find an assignment that contains this token's position. + * Searches through all assignments (script fields and external fields). + */ + private AssignmentInfo findAssignmentContainingPosition(Token token) { + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) { + return null; + } + + ScriptDocument doc = line.getParent(); + int tokenStart = token.getGlobalStart(); + + // Use ScriptDocument's method which handles all prioritization + return doc.findAssignmentAtPosition(tokenStart); + } + + /** + * Find the method call that contains this token's position within its argument list. + */ + private MethodCallInfo findMethodCallContainingPosition(Token token) { + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) { + return null; + } + + ScriptDocument doc = line.getParent(); + int tokenStart = token.getGlobalStart(); + + for (MethodCallInfo call : doc.getMethodCalls()) { + boolean isWithinName = tokenStart >= call.getMethodNameStart() && tokenStart <= call.getMethodNameEnd(); + + // If this is an enum constant, return methodCall on name itself + if (token.isEnumConstant() && isWithinName) + return call; + + // Check if token is within the argument list + if (tokenStart >= call.getOpenParenOffset() && tokenStart <= call.getCloseParenOffset()) { + // Make sure it's not the method name itself + if (isWithinName) { + continue; + } + return call; + } + } + return null; + } + + /** + * Find the method declaration that contains this token's position. + * Returns null if the token is not within a method declaration (header or body). + */ + private MethodInfo findMethodDeclarationContainingPosition(Token token) { + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) { + return null; + } + + ScriptDocument doc = line.getParent(); + int tokenStart = token.getGlobalStart(); + + for (MethodInfo method : doc.getAllMethods()) { + if (!method.isDeclaration()) + continue; + + // Check if token is within the method declaration header OR body + int methodStart = method.getFullDeclarationOffset(); + if (methodStart < 0) methodStart = method.getTypeOffset(); + int bodyEnd = method.getBodyEnd(); + + // Token is within the method (header + body) + if (tokenStart >= methodStart && tokenStart <= bodyEnd) { + return method; + } + } + return null; + } + + private ScriptTypeInfo findScriptTypeContainingPosition(Token token) { + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) { + return null; + } + + ScriptDocument doc = line.getParent(); + int tokenStart = token.getGlobalStart(); + + for (ScriptTypeInfo scriptType : doc.getScriptTypes()) { + int typeStart = scriptType.getDeclarationOffset(); + int typeEnd = scriptType.getBodyStart(); + + // Token is within the type declaration + if (tokenStart >= typeStart && tokenStart <= typeEnd) { + return scriptType; + } + } + return null; + } + + private EnumConstantInfo findEnumConstantContainingPosition(Token token) { + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) { + return null; + } + + ScriptDocument doc = line.getParent(); + int tokenStart = token.getGlobalStart(); + + for (EnumConstantInfo enumConst : doc.getAllEnumConstants()) { + int constStart = enumConst.getDeclarationOffset(); + int constEnd = constStart + enumConst.getName().length(); + + // Token is within the enum constant declaration + if (tokenStart >= constStart && tokenStart <= constEnd) { + return enumConst; + } + } + return null; + } + + /** + * Find the argument that contains the given position. + */ + private MethodCallInfo.Argument findArgumentContainingPosition(MethodCallInfo callInfo, int position) { + for (MethodCallInfo.Argument arg : callInfo.getArguments()) { + if (position >= arg.getStartOffset() && position <= arg.getEndOffset()) { + return arg; + } + } + return null; + } + + /** + * Find the type error for a specific argument. + */ + private MethodCallInfo.ArgumentTypeError findArgumentError(MethodCallInfo callInfo, MethodCallInfo.Argument argument) { + if (!callInfo.hasArgTypeError()) { + return null; + } + + int argIndex = callInfo.getArguments().indexOf(argument); + if (argIndex < 0) { + return null; + } + + for (MethodCallInfo.ArgumentTypeError error : callInfo.getArgumentTypeErrors()) { + if (error.getArgIndex() == argIndex) { + return error; + } + } + return null; + } + + private int getExpectedArgCount(MethodCallInfo callInfo) { + MethodInfo method = callInfo.getResolvedMethod(); + if (method != null) { + return method.getParameterCount(); + } + return 0; + } + + private void extractClassInfo(Token token) { + TypeInfo typeInfo = token.getTypeInfo(); + if (typeInfo == null) return; + + packageName = typeInfo.getPackageName(); + + Class clazz = typeInfo.getJavaClass(); + if (clazz != null) { + boolean isEnum = clazz.isEnum(); + // Icon + if (clazz.isInterface()) { + iconIndicator = "I"; + } else if (isEnum) { + iconIndicator = "E"; + } else { + iconIndicator = "C"; + } + + // Build declaration + int mods = clazz.getModifiers(); + + // Modifiers + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isAbstract(mods) && !clazz.isInterface()) addSegment("abstract ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isFinal(mods) && !isEnum) addSegment("final ", TokenType.MODIFIER.getHexColor()); + + // Class type keyword + if (clazz.isInterface()) { + addSegment("interface ", TokenType.MODIFIER.getHexColor()); + } else if (isEnum) { + addSegment("enum ", TokenType.MODIFIER.getHexColor()); + } else { + addSegment("class ", TokenType.MODIFIER.getHexColor()); + } + + // Class name - use proper color based on type + int classColor = clazz.isInterface() ? TokenType.INTERFACE_DECL.getHexColor() + : clazz.isEnum() ? TokenType.ENUM_DECL.getHexColor() + : TokenType.IMPORTED_CLASS.getHexColor(); + // Render generics with correct segment colors if present + addTypeSegments(typeInfo); + + // Extends + Class superclass = clazz.getSuperclass(); + if (superclass != null && superclass != Object.class && !isEnum) { + addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(superclass.getSimpleName(), getColorForClass(superclass)); + } + + List declaration = null; + + // Implements + Class[] interfaces = clazz.getInterfaces(); + if (interfaces.length > 0) { + addSegment(clazz.isInterface() ? " extends " : " implements ", TokenType.MODIFIER.getHexColor()); + for (int i = 0; i < Math.min(interfaces.length, 3); i++) { + if (i > 0) addSegment(", ", TokenType.DEFAULT.getHexColor()); + addSegment(interfaces[i].getSimpleName(), TokenType.INTERFACE_DECL.getHexColor()); + } + if (interfaces.length > 3) { + addSegment(", ...", TokenType.DEFAULT.getHexColor()); + } + } + } else if (typeInfo instanceof ScriptTypeInfo) { + // Script-defined type + ScriptTypeInfo scriptType = (ScriptTypeInfo) typeInfo; + + // Icon + if (scriptType.getKind() == TypeInfo.Kind.INTERFACE) { + iconIndicator = "I"; + } else if (scriptType.getKind() == TypeInfo.Kind.ENUM) { + iconIndicator = "E"; + } else { + iconIndicator = "C"; + } + + // Build declaration with modifiers + int mods = scriptType.getModifiers(); + + // Modifiers + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isAbstract(mods) && scriptType.getKind() != TypeInfo.Kind.INTERFACE) + addSegment("abstract ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isFinal(mods)) addSegment("final ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isStatic(mods)) addSegment("static ", TokenType.MODIFIER.getHexColor()); + + // Class type keyword + if (scriptType.getKind() == TypeInfo.Kind.INTERFACE) { + addSegment("interface ", TokenType.MODIFIER.getHexColor()); + } else if (scriptType.getKind() == TypeInfo.Kind.ENUM) { + addSegment("enum ", TokenType.MODIFIER.getHexColor()); + } else { + addSegment("class ", TokenType.MODIFIER.getHexColor()); + } + + // Class name + int classColor = scriptType.getKind() == TypeInfo.Kind.INTERFACE ? TokenType.INTERFACE_DECL.getHexColor() + : scriptType.getKind() == TypeInfo.Kind.ENUM ? TokenType.ENUM_DECL.getHexColor() + : TokenType.IMPORTED_CLASS.getHexColor(); + addTypeSegments(typeInfo); + + // Extends clause for ScriptTypeInfo + if (scriptType.hasSuperClass()) { + addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + TypeInfo superClass = scriptType.getSuperClass(); + if (superClass != null && superClass.isResolved()) { + // Color based on resolved type + addTypeSegments(superClass); + } else { + // Unresolved - show the raw name in undefined color + String superName = scriptType.getSuperClassName(); + if (superName != null) { + addSegment(superName, TokenType.UNDEFINED_VAR.getHexColor()); + } + } + } + + // Implements clause for ScriptTypeInfo + List implementedInterfaces = scriptType.getImplementedInterfaces(); + if (!implementedInterfaces.isEmpty()) { + // For interfaces, they "extend" other interfaces; for classes, they "implement" + String keyword = scriptType.getKind() == TypeInfo.Kind.INTERFACE ? " extends " : " implements "; + if (scriptType.hasSuperClass() && scriptType.getKind() == TypeInfo.Kind.INTERFACE) { + // Already showed extends, so use comma + addSegment(", ", TokenType.DEFAULT.getHexColor()); + } else { + addSegment(keyword, TokenType.MODIFIER.getHexColor()); + } + + List interfaceNames = scriptType.getImplementedInterfaceNames(); + for (int i = 0; i < implementedInterfaces.size(); i++) { + if (i > 0) addSegment(", ", TokenType.DEFAULT.getHexColor()); + + TypeInfo ifaceType = implementedInterfaces.get(i); + String ifaceName = (i < interfaceNames.size()) ? interfaceNames.get(i) : getName(ifaceType); + + if (ifaceType != null && ifaceType.isResolved()) { + // Prefer rendering from the resolved TypeInfo so generics color correctly + addTypeSegments(ifaceType); + } else { + addSegment(ifaceName, TokenType.UNDEFINED_VAR.getHexColor()); + } + } + } + + // Add ScriptTypeInfo errors + if (false) { //|| scriptType.hasError() + // Missing interface method errors + for (ScriptTypeInfo.MissingMethodError err : scriptType.getMissingMethodErrors()) { + errors.add(err.getMessage()); + } + // Constructor mismatch errors + for (ScriptTypeInfo.ConstructorMismatchError err : scriptType.getConstructorMismatchErrors()) { + errors.add(err.getMessage()); + } + // General error message + if (scriptType.getErrorMessage() != null) { + errors.add(scriptType.getErrorMessage()); + } + } + + JSDocInfo jsDocInfo = scriptType.getJSDocInfo(); + if (jsDocInfo != null) { + formatJSDocumentation(jsDocInfo, null); + } + + } else if (typeInfo.isJSType()) { + JSTypeInfo jsType = typeInfo.getJSTypeInfo(); + + iconIndicator = "I"; + addSegment("interface ", TokenType.MODIFIER.getHexColor()); + addTypeSegments(typeInfo); + + if (jsType.getExtendsType() != null) { + addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(jsType.getExtendsType(), TokenType.INTERFACE_DECL.getHexColor()); + } + + JSDocInfo jsDocInfo = typeInfo.getJSDocInfo(); + if (jsDocInfo != null) { + formatJSDocumentation(jsDocInfo, null); + } + + } else { + // Unresolved type + iconIndicator = "?"; + addSegment(getName(typeInfo), token.getType().getHexColor()); + if (!typeInfo.isResolved()) { + errors.add("Cannot resolve class '" + getName(typeInfo) + "'"); + } + } + + // If this is a NEW_TYPE token with constructor info, show the constructor signature + if (token.getMethodInfo() != null) { + MethodInfo constructor = token.getMethodInfo(); + declaration.add(new TextSegment("\n", TokenType.DEFAULT.getHexColor())); + additionalInfo.add("Constructor"); + buildConstructorDeclaration(constructor, typeInfo); + } + } + + private void extractMethodCallInfo(Token token) { + MethodCallInfo callInfo = token.getMethodCallInfo(); + MethodInfo methodInfo = callInfo != null ? callInfo.getResolvedMethod() : token.getMethodInfo(); + + if (methodInfo == null && callInfo == null) return; + + iconIndicator = "m"; + + TypeInfo containingType = null; + if (callInfo != null) { + containingType = callInfo.getReceiverType(); + + //Checks true containing type from resolved method (handles inheritance cases) + if (callInfo.getResolvedMethod() != null) { + TypeInfo trueContainingType = callInfo.getResolvedMethod().getContainingType(); + if (trueContainingType != null) + containingType = trueContainingType; + } + } + if (containingType == null && methodInfo != null) { + containingType = methodInfo.getContainingType(); + } + + if (containingType != null) { + // Show full qualified class name (like IntelliJ) + String pkg = getPackageName(containingType); + if (pkg != null && !pkg.isEmpty()) + packageName = pkg; + } + + // Try to get actual Java method for more details + if (methodInfo != null && shouldPreferMethodInfo(methodInfo)) { + buildBasicMethodDeclaration(methodInfo, containingType); + return; + } + if (methodInfo != null && methodInfo.getJavaMethod() != null) { + TypeInfo methodReturnType = methodInfo.getReturnType(); + Class reflectedReturnType = methodInfo.getJavaMethod().getReturnType(); + if (methodReturnType != null && methodReturnType.getJavaClass() != null + && methodReturnType.getJavaClass() != reflectedReturnType) { + // Use MethodInfo when its return type differs from reflection (e.g., .d.ts override like IDBCPlayer -> IDBCAddon in npcdbc). + buildBasicMethodDeclaration(methodInfo, containingType); + return; + } + buildMethodDeclaration(methodInfo.getJavaMethod(), containingType); + extractJavadoc(methodInfo.getJavaMethod()); + return; + } + + // Fallback to basic method info + if (methodInfo != null) { + buildBasicMethodDeclaration(methodInfo, containingType); + } + } + + private void extractMethodDeclInfo(Token token) { + MethodInfo methodInfo = token.getMethodInfo(); + if (methodInfo == null) return; + + iconIndicator = "m"; + + // For script-defined methods, show basic declaration + buildBasicMethodDeclaration(methodInfo, null); + } + + private void extractGlobalFieldInfo(Token token) { + FieldInfo fieldInfo = token.getFieldInfo(); + FieldAccessInfo accessInfo = token.getFieldAccessInfo(); + if (fieldInfo == null && accessInfo != null) + fieldInfo = accessInfo.getResolvedField(); + + if (fieldInfo == null) + return; + + iconIndicator = "f"; + + // Add documentation if available + JSDocInfo jsDoc = fieldInfo.getJSDocInfo(); + if (jsDoc != null) { + formatJSDocumentation(jsDoc, null); + } else if (fieldInfo.getDocumentation() != null && !fieldInfo.getDocumentation().isEmpty()) { + String[] docLines = fieldInfo.getDocumentation().split("\n"); + for (String line : docLines) { + documentation.add(line); + } + } + + TypeInfo declaredType = fieldInfo.getTypeInfo(); + + // Try to get modifiers from Java reflection if this is a chained field + boolean foundModifiers = false; + if (accessInfo != null && accessInfo.getReceiverType() != null) { + TypeInfo receiverType = accessInfo.getReceiverType(); + if (receiverType.getJavaClass() != null) { + try { + Field javaField = receiverType.getJavaClass().getField(fieldInfo.getName()); + if (javaField != null) + addFieldModifiers(javaField.getModifiers()); + foundModifiers = true; + } catch (Exception e) { + } + } + } + + int modifiers = fieldInfo.getModifiers(); + if(modifiers !=0 && !foundModifiers) { + addFieldModifiers(modifiers); + } + +// if (!foundModifiers && fieldInfo.getDeclarationOffset() >= 0) { +// // Show modifiers from source if we have a declaration position +// String modifiers = extractModifiersAtPosition(fieldInfo.getDeclarationOffset()); +// if (modifiers != null && !modifiers.isEmpty()) { +// addSegment(modifiers + " ", TokenType.MODIFIER.getHexColor()); +// } +// } + + if (declaredType != null) { + // accessInfo For Minecraft.getMinecraft.thePlayer + // Return net.minecraft.client.Minecraft, and not net.minecraft.entity.EntityPlayer + String pkg = getPackageName(accessInfo != null ? accessInfo.getReceiverType() : declaredType); + + if (pkg != null && !pkg.isEmpty()) + packageName = pkg; + + // Type - check for actual type color + addTypeSegments(declaredType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + + // Field name + addSegment(fieldInfo.getName(), TokenType.GLOBAL_FIELD.getHexColor()); + + // Add initialization value if available + addInitializationTokens(token, fieldInfo); + } + + private void extractEnumConstantInfo(Token token) { + FieldInfo fieldInfo = token.getFieldInfo(); + if (fieldInfo == null) + return; + + EnumConstantInfo enumInfo = fieldInfo.getEnumInfo(); + if (enumInfo == null) + return; + + iconIndicator = "e"; + + // Add documentation if available + if (fieldInfo.getDocumentation() != null && !fieldInfo.getDocumentation().isEmpty()) { + String[] docLines = fieldInfo.getDocumentation().split("\n"); + for (String line : docLines) { + documentation.add(line); + } + } + + TypeInfo enumType = enumInfo.getEnumType(); + + if (enumType != null) { + // Show enum's package + String pkg = getPackageName(enumType); + if (pkg != null && !pkg.isEmpty()) + packageName = pkg; + + + // Type (enum type name) + addTypeSegments(enumType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + + // Enum constant name + addSegment(token.getStylePrefix() + fieldInfo.getName(), TokenType.ENUM_CONSTANT.getHexColor()); + + // Add constructor arguments if available + addInitializationTokens(token, fieldInfo); + } + + private void extractLocalFieldInfo(Token token) { + FieldInfo fieldInfo = token.getFieldInfo(); + if (fieldInfo == null) return; + + iconIndicator = "v"; + + // Add documentation if available (though local vars rarely have docs) + if (fieldInfo.getDocumentation() != null && !fieldInfo.getDocumentation().isEmpty()) { + String[] docLines = fieldInfo.getDocumentation().split("\n"); + for (String line : docLines) { + documentation.add(line); + } + } + + TypeInfo declaredType = fieldInfo.getTypeInfo(); + if (declaredType != null) { + addTypeSegments(declaredType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + + addSegment(fieldInfo.getName(), TokenType.LOCAL_FIELD.getHexColor()); + + // Add initialization value if available + addInitializationTokens(token, fieldInfo); + + // Show it's a local variable + additionalInfo.add("Local variable"); + } + + public String getPackageName(TypeInfo type) { + if (type == null) + return null; + + String fullName = type.getFullName(); + if (fullName != null && !fullName.isEmpty()) { + return fullName; + } else { + String pkg = type.getPackageName(); + String className = getName(type); + if (pkg != null && !pkg.isEmpty()) { + return pkg + "." + className; + } else { + return className; + } + } + } + + private void extractParameterInfo(Token token) { + FieldInfo fieldInfo = token.getFieldInfo(); + if (fieldInfo == null) return; + + iconIndicator = "p"; + + // Add documentation if available (args/parameters might have docs from method javadoc) + if (fieldInfo.getDocumentation() != null && !fieldInfo.getDocumentation().isEmpty()) { + String[] docLines = fieldInfo.getDocumentation().split("\n"); + for (String line : docLines) { + documentation.add(line); + } + } + + TypeInfo declaredType = fieldInfo.getTypeInfo(); + if (declaredType != null) { + addTypeSegments(declaredType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + + addSegment(fieldInfo.getName(), TokenType.PARAMETER.getHexColor()); + + additionalInfo.add("Parameter"); + } + + private void extractUndefinedInfo(Token token) { + iconIndicator = "?"; + //addSegment(token.getText(), TokenType.UNDEFINED_VAR.getHexColor()); + } + + /** + * Get the display name for a type, including generic arguments. + * Uses the unified getDisplayName() method which handles: + * - Simple types: "String" + * - Parameterized types: "List", "Map" + * - Nested generics: "List>" + * - JS types with namespace: "IPlayerEvent.InteractEvent" + */ + public String getName(TypeInfo type){ + return type.getDisplayName(); + } + + /** + * Add a type name as multiple colored segments so generics render like the editor marks. + * Example: List -> "List" (interface), "<" (default), "String" (class), ">" (default) + */ + private void addTypeSegments(TypeInfo typeInfo) { + addTypeSegments(typeInfo, 0); + } + + private void addTypeSegments(TypeInfo typeInfo, int depth) { + if (typeInfo == null) { + addSegment("any", TokenType.DEFAULT.getHexColor()); + return; + } + if (depth > 25) { + // Safety guard against pathological recursive types + addSegment(getName(typeInfo), getColorForTypeInfo(typeInfo)); + return; + } + + if (typeInfo.isParameterized()) { + TypeInfo raw = typeInfo.getRawType(); + addSegment(getName(raw), getColorForTypeInfo(raw)); + addSegment("<", TokenType.DEFAULT.getHexColor()); + + java.util.List args = typeInfo.getAppliedTypeArgs(); + for (int i = 0; i < args.size(); i++) { + if (i > 0) { + addSegment(", ", TokenType.DEFAULT.getHexColor()); + } + addTypeSegments(args.get(i), depth + 1); + } + + addSegment(">", TokenType.DEFAULT.getHexColor()); + return; + } + + addSegment(getName(typeInfo), getColorForTypeInfo(typeInfo)); + } + + private void extractFieldInfoGeneric(Token token) { + FieldInfo fieldInfo = token.getFieldInfo(); + if (fieldInfo == null) return; + + switch (fieldInfo.getScope()) { + case GLOBAL: + extractGlobalFieldInfo(token); + break; + case LOCAL: + extractLocalFieldInfo(token); + break; + case PARAMETER: + extractParameterInfo(token); + break; + } + } + + private void buildConstructorDeclaration(MethodInfo constructor, TypeInfo containingType) { + // Modifiers (public, private, etc.) + declaration.clear(); + int mods = constructor.getModifiers(); + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); + else if (Modifier.isProtected(mods)) addSegment("protected ", TokenType.MODIFIER.getHexColor()); + else if (Modifier.isPrivate(mods)) addSegment("private ", TokenType.MODIFIER.getHexColor()); + + // Constructor name (same as class name) + addSegment(constructor.getName(), containingType.getTokenType().getHexColor()); + + // Parameters + addSegment("(", TokenType.DEFAULT.getHexColor()); + List params = constructor.getParameters(); + for (int i = 0; i < params.size(); i++) { + if (i > 0) addSegment(", ", TokenType.DEFAULT.getHexColor()); + FieldInfo param = params.get(i); + TypeInfo paramType = param.getTypeInfo(); + if (paramType != null) { + addTypeSegments(paramType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + addSegment(param.getName(), TokenType.PARAMETER.getHexColor()); + } + addSegment(")", TokenType.DEFAULT.getHexColor()); + + // Add documentation if available + if (constructor.getDocumentation() != null && !constructor.getDocumentation().isEmpty()) { + String[] docLines = constructor.getDocumentation().split("\n"); + for (String line : docLines) { + documentation.add(line); + } + } + } + + private void addSegment(String text, int color) { + declaration.add(new TextSegment(text, color)); + } + + private void buildMethodDeclaration(Method method, TypeInfo containingType) { + int mods = method.getModifiers(); + + // Annotations (show @Contract if present, etc.) + // Skip for now - could add later + + // Modifiers + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); + else if (Modifier.isProtected(mods)) addSegment("protected ", TokenType.MODIFIER.getHexColor()); + else if (Modifier.isPrivate(mods)) addSegment("private ", TokenType.MODIFIER.getHexColor()); + + if (Modifier.isStatic(mods)) addSegment("static ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isFinal(mods)) addSegment("final ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isAbstract(mods)) addSegment("abstract ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isSynchronized(mods)) addSegment("synchronized ", TokenType.MODIFIER.getHexColor()); + + // Return type - check for actual type color + Class returnType = method.getReturnType(); + int returnTypeColor = getColorForClass(returnType); + addSegment(returnType.getSimpleName(), returnTypeColor); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + + // Method name + addSegment(method.getName(), TokenType.METHOD_DECL.getHexColor()); + + // Parameters + addSegment("(", TokenType.DEFAULT.getHexColor()); + Class[] paramTypes = method.getParameterTypes(); + java.lang.reflect.Parameter[] params = method.getParameters(); + for (int i = 0; i < paramTypes.length; i++) { + if (i > 0) addSegment(", ", TokenType.DEFAULT.getHexColor()); + int paramTypeColor = getColorForClass(paramTypes[i]); + addSegment(paramTypes[i].getSimpleName(), paramTypeColor); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + // Try to get parameter name if available + String paramName = params.length > i ? params[i].getName() : "arg" + i; + addSegment(paramName, TokenType.PARAMETER.getHexColor()); + } + addSegment(")", TokenType.DEFAULT.getHexColor()); + } + + private void buildBasicMethodDeclaration(MethodInfo methodInfo, TypeInfo containingType) { + // Try to extract modifiers from source if this is a declaration + if (methodInfo.isDeclaration() && methodInfo.getDeclarationOffset() >= 0) { + String modifiers = extractModifiersAtPosition(methodInfo.getDeclarationOffset()); + if (modifiers != null && !modifiers.isEmpty()) { + addSegment(modifiers + " ", TokenType.MODIFIER.getHexColor()); + } + } else { + // Fallback: show static if we know it + if (methodInfo.isStatic()) { + addSegment("static ", TokenType.MODIFIER.getHexColor()); + } + } + + // Return type + TypeInfo returnType = methodInfo.getReturnType(); + if (returnType != null) { + addTypeSegments(returnType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } else { + addSegment("void ", TokenType.KEYWORD.getHexColor()); + } + + // Method name + addSegment(methodInfo.getName(), TokenType.METHOD_DECL.getHexColor()); + + // Parameters + addSegment("(", TokenType.DEFAULT.getHexColor()); + List params = methodInfo.getParameters(); + for (int i = 0; i < params.size(); i++) { + if (i > 0) addSegment(", ", TokenType.DEFAULT.getHexColor()); + FieldInfo param = params.get(i); + TypeInfo paramType = param.getTypeInfo(); + if (paramType != null) { + addTypeSegments(paramType); + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + addSegment(param.getName(), TokenType.PARAMETER.getHexColor()); + } + addSegment(")", TokenType.DEFAULT.getHexColor()); + + // Add documentation if available - use JSDoc formatting if we have JSDocInfo + JSDocInfo jsDoc = methodInfo.getJSDocInfo(); + if (jsDoc != null) { + formatJSDocumentation(jsDoc, methodInfo.getParameters()); + } else if (methodInfo.getDocumentation() != null && !methodInfo.getDocumentation().isEmpty()) { + // Fallback to raw documentation + String[] docLines = methodInfo.getDocumentation().split("\n"); + for (String line : docLines) { + documentation.add(line); + } + } + + } + + private boolean shouldPreferMethodInfo(MethodInfo methodInfo) { + if (methodInfo.getJSDocInfo() != null) return true; + String doc = methodInfo.getDocumentation(); + if (doc != null && !doc.isEmpty()) return true; + + for (FieldInfo param : methodInfo.getParameters()) { + String name = param.getName(); + if (name != null && !name.matches("arg\\d+")) { + return true; + } + } + return false; + } + + /** + * Format JSDoc information in IntelliJ-style with "Params:" and "Returns:" sections. + * Creates colored documentation lines with parameter names highlighted. + */ + private void formatJSDocumentation(JSDocInfo jsDoc, List methodParams) { + // Add description if available (without @tags) + String description = jsDoc.getDescription(); + if (description != null && !description.isEmpty()) { + // Clean up the description - remove leading/trailing whitespace and asterisks + String[] descLines = description.split("\n"); + for (String line : descLines) { + line = line.trim(); + if (line.startsWith("*")) { + line = line.substring(1).trim(); + } + if (!line.isEmpty() && !line.startsWith("@")) { + documentation.add(line); + } + } + } + + // Add Returns section if there's a @return tag + JSDocTypeTag typeTag = jsDoc.getTypeTag(); + if (typeTag != null) { + // "Type:" header + DocumentationLine typeLine = new DocumentationLine(); + typeLine.addSegment("Type:", TokenType.JSDOC_TAG.getHexColor()); + + // Type if available + if (typeTag.hasType()) { + typeLine.addText(" "); + typeLine.addSegment("{", TokenType.JSDOC_TYPE.getHexColor()); + typeLine.addSegment(typeTag.getTypeName(), TokenType.getColor(typeTag.getTypeInfo())); + typeLine.addSegment("}", TokenType.JSDOC_TYPE.getHexColor()); + } + + // Description if available + String typeDesc = typeTag.getDescription(); + if (typeDesc != null && !typeDesc.isEmpty()) { + typeLine.addText(" - "); + typeLine.addText(typeDesc.trim()); + } + + jsDocLines.add(typeLine); + } + + // Add Params section if there are @param tags + List paramTags = jsDoc.getParamTags(); + if (paramTags != null && !paramTags.isEmpty()) { + // "Params:" header + DocumentationLine paramsHeader = new DocumentationLine(); + paramsHeader.addSegment("Params:", TokenType.JSDOC_TAG.getHexColor()); + jsDocLines.add(paramsHeader); + + // Add each parameter + for (JSDocParamTag paramTag : paramTags) { + DocumentationLine paramLine = new DocumentationLine(); + + // Indent and parameter name + String paramName = paramTag.getParamName(); + if (paramName == null || paramName.isEmpty()) { + paramLine.addSegment("param", TokenType.JSDOC_TAG.getHexColor()); + } else { + boolean paramExists = methodParams != null && methodParams.stream() + .anyMatch(p -> p.getName().equals(paramName)); + paramLine.addSegment(paramName, + paramExists ? TokenType.PARAMETER.getHexColor() : TokenType.UNDEFINED_VAR.getHexColor()); + } + + // Type if available + if (paramTag.hasType()) { + paramLine.addSegment(" {", TokenType.JSDOC_TYPE.getHexColor()); + paramLine.addSegment(paramTag.getTypeName(), TokenType.getColor(paramTag.getTypeInfo())); + paramLine.addSegment("}", TokenType.JSDOC_TYPE.getHexColor()); + } + + // Description if available + String paramDesc = paramTag.getDescription(); + if (paramDesc != null && !paramDesc.isEmpty()) { + paramLine.addText(" - "); + paramLine.addText(paramDesc.trim()); + } + + jsDocLines.add(paramLine); + } + } + + // Add Returns section if there's a @return tag + JSDocReturnTag returnTag = jsDoc.getReturnTag(); + if (returnTag != null) { + DocumentationLine returnLine = new DocumentationLine(); + + //"Returns:" header + returnLine.addSegment("Returns:", TokenType.JSDOC_TAG.getHexColor()); + + // Type if available + if (returnTag.hasType()) { + returnLine.addText(" "); + returnLine.addSegment("{", TokenType.JSDOC_TYPE.getHexColor()); + returnLine.addSegment(returnTag.getTypeName(), TokenType.getColor(returnTag.getTypeInfo())); + returnLine.addSegment("}", TokenType.JSDOC_TYPE.getHexColor()); + } + + // Description if available + String returnDesc = returnTag.getDescription(); + if (returnDesc != null && !returnDesc.isEmpty()) { + returnLine.addText(" - "); + returnLine.addText(returnDesc.trim()); + } + + jsDocLines.add(returnLine); + } + + List seeTags = jsDoc.getSeeTags(); + if (seeTags != null && !seeTags.isEmpty()) { + for (JSDocSeeTag seeTag : seeTags) { + DocumentationLine seeLine = new DocumentationLine(); + seeLine.addSegment("See:", TokenType.JSDOC_TAG.getHexColor()); + + String reference = seeTag.getReference(); + if (seeTag.hasLinkText()) { + seeLine.addText(" "); + seeLine.addSegment(seeTag.getLinkText(), TokenType.INTERFACE_DECL.getHexColor()); + } else if (reference != null) { + seeLine.addText(" " + reference); + } + + jsDocLines.add(seeLine); + } + } + + List allTags = jsDoc.getAllTags(); + if (allTags != null && !allTags.isEmpty()) { + for (JSDocTag tag : allTags) { + String tagName = tag.getTagName(); + if (tagName == null) { + continue; + } + + String normalized = tagName.toLowerCase(); + if ("type".equals(normalized) + || "param".equals(normalized) + || "return".equals(normalized) + || "returns".equals(normalized) + || "see".equals(normalized)) { + continue; + } + + DocumentationLine tagLine = new DocumentationLine(); + //capitalise first letter in tag name + String capTagName = tagName.substring(0, 1).toUpperCase() + tagName.substring(1); + tagLine.addSegment(capTagName + ":", TokenType.JSDOC_TAG.getHexColor()); + + if (tag.hasType()) { + tagLine.addSegment(" {", TokenType.JSDOC_TYPE.getHexColor()); + tagLine.addSegment(tag.getTypeName(), TokenType.getColor(tag.getTypeInfo())); + tagLine.addSegment("}", TokenType.JSDOC_TYPE.getHexColor()); + } + + String tagDesc = tag.getDescription(); + if (tagDesc != null && !tagDesc.isEmpty()) { + tagLine.addText(" - "); + tagLine.addText(tagDesc.trim()); + } + + jsDocLines.add(tagLine); + } + } + } + + private void extractJavadoc(Method method) { + // Java reflection doesn't provide Javadoc at runtime + // We could potentially load it from source files or external documentation + // For now, we'll leave this as a placeholder for future enhancement + + // Check for @Deprecated annotation + if (method.isAnnotationPresent(Deprecated.class)) { + additionalInfo.add("@Deprecated"); + } + } + + /** + * Extract modifiers (public, private, static, final, etc.) before a declaration position. + * Looks backward from the position to find modifiers. + */ + private String extractModifiersAtPosition(int position) { + if (token.getParentLine().getParent() == null) + return null; + + String text = token.getParentLine().getParent().getText(); + if (position < 0 || position >= text.length()) + return null; + + // Look backward from position to find start of line or previous semicolon/brace + int searchStart = position - 1; + while (searchStart >= 0) { + char c = text.charAt(searchStart); + if (c == ';' || c == '{' || c == '}' || c == '\n') { + searchStart++; + break; + } + searchStart--; + } + if (searchStart < 0) + searchStart = 0; + + // Extract text before the declaration + String beforeDecl = text.substring(searchStart, position).trim(); + + // Match modifier keywords + StringBuilder modifiers = new StringBuilder(); + String[] words = beforeDecl.split("\\s+"); + for (String word : words) { + if (TypeResolver.isModifier(word)) { + if (modifiers.length() > 0) + modifiers.append(" "); + modifiers.append(word); + } + } + + return modifiers.toString(); + } + + /** + * Add field modifiers from Java reflection modifiers. + */ + private void addFieldModifiers(int mods) { + if (Modifier.isPublic(mods)) + addSegment("public ", TokenType.MODIFIER.getHexColor()); + else if (Modifier.isProtected(mods)) + addSegment("protected ", TokenType.MODIFIER.getHexColor()); + else if (Modifier.isPrivate(mods)) + addSegment("private ", TokenType.MODIFIER.getHexColor()); + + if (Modifier.isStatic(mods)) + addSegment("static ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isFinal(mods)) + addSegment("final ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isVolatile(mods)) + addSegment("volatile ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isTransient(mods)) + addSegment("transient ", TokenType.MODIFIER.getHexColor()); + } + + /** + * Get the appropriate color for a Class based on its type. + */ + private int getColorForClass(Class clazz) { + if(clazz.isPrimitive()) return TokenType.KEYWORD.getHexColor(); + if (clazz.isInterface()) return TokenType.INTERFACE_DECL.getHexColor(); + if (clazz.isEnum()) return TokenType.ENUM_DECL.getHexColor(); + return TokenType.IMPORTED_CLASS.getHexColor(); + } + + /** + * Get the appropriate color for a TypeInfo based on its type kind. + * Works for both Java-backed TypeInfo and ScriptTypeInfo. + */ + private int getColorForTypeInfo(TypeInfo typeInfo) { + // Use the TypeInfo's own token type, which handles ScriptTypeInfo correctly + return TokenType.getColor(typeInfo); + } + + /** + * Add initialization tokens from the field's initializer to the declaration. + * Fetches the tokens in the initialization range and adds them with their proper coloring. + */ + private void addInitializationTokens(Token token, FieldInfo fieldInfo) { + if (!fieldInfo.hasInitializer()) return; + + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) return; + + ScriptDocument doc = line.getParent(); + // Include the semicolon by extending range by 1 + List initTokens = doc.getTokensInRange(fieldInfo.getInitStart(), fieldInfo.getInitEnd() + 1); + + if (initTokens.isEmpty()) return; + + // Add space before '=' for readability + addSegment(" ", TokenType.DEFAULT.getHexColor()); + + // Add each token with its proper color, ensuring normalized spacing between tokens + String lastText = null; + for (Token initToken : initTokens) { + String text = initToken.getText(); + + // Normalize whitespace - replace all newlines and multiple spaces with single space + text = text.replaceAll("\\s+", " ").trim(); + + // Skip if token became empty after whitespace removal + if (text.isEmpty()) { + continue; + } + + // Determine if we need a space between last token and current token + if (lastText != null && shouldAddSpace(lastText, text)) { + addSegment(" ", TokenType.DEFAULT.getHexColor()); + } + + addSegment(text, initToken.getType().getHexColor()); + lastText = text; + } + } + + /** + * Determine if a space should be added between two tokens. + */ + private boolean shouldAddSpace(String lastToken, String currentToken) { + if (lastToken.isEmpty() || currentToken.isEmpty()) + return false; + + char lastChar = lastToken.charAt(lastToken.length() - 1); + char firstChar = currentToken.charAt(0); + + // Never add space before these closing/trailing characters + if (firstChar == '(' || firstChar == '[' || firstChar == '{' || + firstChar == '.' || firstChar == ',' || firstChar == ';' || + firstChar == '>' || // No space before closing angle bracket + (firstChar == ':' && lastToken.equals("?"))) { + return false; + } + + // Never add space after these opening/leading characters + if (lastChar == '(' || lastChar == '[' || lastChar == '{' || + lastChar == '.' || lastChar == '<') { // No space after opening angle bracket + return false; + } + + // Add space around operators (check last/first characters) + if (isOperatorChar(lastChar) || isOperatorChar(firstChar)) { + return true; + } + + // Add space after closing brackets/parens before other tokens + if (lastChar == ')' || lastChar == ']' || lastChar == '}') { + return true; + } + + // Add space after colons and commas (except after ?: ternary) + if ((lastChar == ':' && !lastToken.equals("?:")) || lastChar == ',') { + return true; + } + + // Add space between identifiers/keywords and numbers + boolean lastIsIdentifier = Character.isLetterOrDigit(lastChar) || lastChar == '_'; + boolean firstIsIdentifier = Character.isLetterOrDigit(firstChar) || firstChar == '_'; + if (lastIsIdentifier && firstIsIdentifier) { + return true; + } + + return false; + } + + /** + * Check if a character is an operator that needs spacing. + */ + private boolean isOperatorChar(char c) { + return "+-*/%<>=!&|^?:".indexOf(c) >= 0; + } + // ==================== GETTERS ==================== + + public String getPackageName() { return packageName; } + public String getIconIndicator() { return iconIndicator; } + + public List getDeclaration() { + return declaration; + } + public List getDocumentation() { return documentation; } + + public List getJSDocLines() { + return jsDocLines; + } + public List getErrors() { return errors; } + public List getAdditionalInfo() { return additionalInfo; } + public Token getToken() { return token; } + + public boolean hasContent() { + return !declaration.isEmpty() || !errors.isEmpty() || !documentation.isEmpty() || !jsDocLines.isEmpty(); + } + + public boolean hasJSDocContent() { + return !jsDocLines.isEmpty(); + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java new file mode 100644 index 000000000..7f6e34051 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java @@ -0,0 +1,558 @@ +package noppes.npcs.client.gui.util.script.interpreter.hover; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import noppes.npcs.client.ClientProxy; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.List; + +/** + * Renders hover tooltips for tokens in the script editor. + * + * Renders IntelliJ-style tooltips with: + * - Package name in gray + * - Icon indicator (C/I/E/m/f/v/p) + * - Colored declaration line + * - Documentation/Javadoc + * - Error messages in red + * + * Uses a unified approach for width/height calculation based on max available width. + */ +public class TokenHoverRenderer { + + // ==================== CONSTANTS ==================== + + /** Padding inside the tooltip box */ + private static final int PADDING = 6; + + /** Line spacing between rows */ + private static final int LINE_SPACING = 2; + + /** Separator line height */ + private static final int SEPARATOR_HEIGHT = 1; + + /** Spacing above and below separator */ + private static final int SEPARATOR_SPACING = 5; + + /** Vertical offset from token */ + private static final int VERTICAL_OFFSET = 4; + + /** Maximum tooltip width as percentage of viewport */ + private static final float MAX_WIDTH_RATIO = 0.9f; + + /** Minimum tooltip width */ + private static final int MIN_WIDTH = 50; + + // ==================== COLORS ==================== + + /** Background color (dark gray like IntelliJ) */ + private static final int BG_COLOR = 0xF0313335; + + /** Border color */ + private static final int BORDER_COLOR = 0xFF3C3F41; + + /** Package text color */ + private static int PACKAGE_COLOR = 0xFF6490e2; + + /** Error text color */ + private static final int ERROR_COLOR = 0xFFFF6B68; + + /** Info text color */ + private static final int INFO_COLOR = 0xFF808080; + + /** Documentation text color */ + private static final int DOC_COLOR = 0xFFA9B7C6; + + // ==================== RENDERING ==================== + + /** + * Render the hover tooltip. + */ + public static void render(HoverState hoverState, int viewportX, int viewportWidth, int viewportY, int viewportHeight) { + if (!hoverState.isTooltipVisible()) return; + + TokenHoverInfo info = hoverState.getHoverInfo(); + if (info == null || !info.hasContent()) return; + + int lineHeight = ClientProxy.Font.height(); + int tokenX = hoverState.getTokenScreenX(); + int tokenY = hoverState.getTokenScreenY(); + + // Calculate max available width for content + int maxContentWidth = getMaxContentWidth(viewportX, viewportWidth, tokenX); + + // Calculate actual content width needed (clamped to max) + int contentWidth = calculateContentWidth(info, maxContentWidth); + + // Calculate height based on the actual content width (accounts for wrapping) + int contentHeight = calculateContentHeight(info, contentWidth); + + // Box dimensions + int boxWidth = contentWidth + PADDING * 2; + int boxHeight = contentHeight + PADDING * 2; + + // Position the tooltip + int tooltipX = tokenX; + int tooltipY = tokenY + lineHeight + VERTICAL_OFFSET; + + // Clamp X position to viewport + int rightBound = viewportX + viewportWidth; + if (tooltipX + boxWidth > rightBound) { + tooltipX = rightBound - boxWidth; + } + if (tooltipX < viewportX) { + tooltipX = viewportX; + } + + // If box still doesn't fit horizontally, shrink it + int availableWidth = rightBound - tooltipX; + if (boxWidth > availableWidth) { + boxWidth = availableWidth; + } + + // Clamp Y position to viewport + int bottomBound = viewportY + viewportHeight; + if (tooltipY + boxHeight > bottomBound) { + // Try rendering above the token + tooltipY = tokenY - boxHeight - VERTICAL_OFFSET; + } + // If still doesn't fit above, clamp to viewport top + if (tooltipY < viewportY) { + tooltipY = viewportY; + } + + // Render the tooltip - use maxContentWidth for consistent wrapping + renderTooltipBox(tooltipX, tooltipY, boxWidth, maxContentWidth, info); + } + + /** + * Calculate the maximum content width based on viewport and token position. + */ + private static int getMaxContentWidth(int viewportX, int viewportWidth, int tokenX) { + // Max width is based on viewport size (80% by default) + // This scales naturally with viewport size - no artificial hard cap + int maxWidth = (int)(viewportWidth * MAX_WIDTH_RATIO); + + // The tooltip can expand across the entire viewport width + // (positioning logic will shift it left if needed) + return Math.max(MIN_WIDTH, maxWidth); + } + + /** + * Render the tooltip box with all content. + */ + private static void renderTooltipBox(int x, int y, int boxWidth, int wrapWidth, TokenHoverInfo info) { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + + int boxHeight = calculateContentHeight(info, wrapWidth) + PADDING * 2; + + // Draw background + Gui.drawRect(x, y, x + boxWidth, y + boxHeight, BG_COLOR); + + // Draw border + Gui.drawRect(x, y, x + boxWidth, y + 1, BORDER_COLOR); + Gui.drawRect(x, y + boxHeight - 1, x + boxWidth, y + boxHeight, BORDER_COLOR); + Gui.drawRect(x, y, x + 1, y + boxHeight, BORDER_COLOR); + Gui.drawRect(x + boxWidth - 1, y, x + boxWidth, y + boxHeight, BORDER_COLOR); + + int textX = x + PADDING; + int currentY = y + PADDING; + int lineHeight = ClientProxy.Font.height(); + + + String packageName = info.getPackageName(); + List declaration = info.getDeclaration(); + List docs = info.getDocumentation(); + List additionalInfo = info.getAdditionalInfo(); + + // Draw errors first + List errors = info.getErrors(); + if (!errors.isEmpty()) { + for (String error : errors) { + List wrappedLines = wrapText(error, wrapWidth); + for (String line : wrappedLines) { + drawText(textX, currentY, line, ERROR_COLOR); + currentY += lineHeight + LINE_SPACING; + } + } + boolean onlyErrors = errors != null && !errors.isEmpty() && (packageName == null || packageName.isEmpty()) && (declaration == null || + declaration.isEmpty()) && (docs == null || docs.isEmpty()) && (additionalInfo == null || additionalInfo.isEmpty()); + + if (!onlyErrors) { //Add error separator line + currentY += SEPARATOR_HEIGHT + SEPARATOR_SPACING - 5; + Gui.drawRect(textX, currentY, x + boxWidth - PADDING, currentY + SEPARATOR_HEIGHT, BORDER_COLOR); + currentY += SEPARATOR_HEIGHT + SEPARATOR_SPACING; + } + currentY += LINE_SPACING; + } + + // Draw package name + if (packageName != null && !packageName.isEmpty()) { + String packageText = "\u25CB " + packageName; + List wrappedLines = wrapText(packageText, wrapWidth); + for (String line : wrappedLines) { + drawText(textX, currentY, line, PACKAGE_COLOR); + currentY += lineHeight + LINE_SPACING; + } + } + + // Draw declaration (colored segments with wrapping) + if (!declaration.isEmpty()) { + currentY = drawWrappedSegments(textX, currentY, wrapWidth, declaration); + currentY += LINE_SPACING; + } + + // Draw documentation (plain text) + if (!docs.isEmpty()) { + // Draw separator line before documentation + Gui.drawRect(textX, currentY, x + boxWidth - PADDING, currentY + SEPARATOR_HEIGHT, BORDER_COLOR); + currentY += SEPARATOR_HEIGHT + SEPARATOR_SPACING; + for (String doc : docs) { + List wrappedLines = wrapText(doc, wrapWidth); + for (String line : wrappedLines) { + drawText(textX, currentY, line, DOC_COLOR); + currentY += lineHeight + LINE_SPACING; + } + } + } + + // Draw JSDoc-formatted documentation with colored segments + List jsDocLines = info.getJSDocLines(); + if (!jsDocLines.isEmpty()) { + // Draw separator line if there was plain documentation or declaration + if (docs.isEmpty() && !declaration.isEmpty()) { + Gui.drawRect(textX, currentY, x + boxWidth - PADDING, currentY + SEPARATOR_HEIGHT, BORDER_COLOR); + currentY += SEPARATOR_HEIGHT + SEPARATOR_SPACING; + } + + for (TokenHoverInfo.DocumentationLine docLine : jsDocLines) { + if (!docLine.isEmpty()) { + currentY = drawWrappedSegments(textX, currentY, wrapWidth, docLine.segments); + currentY += LINE_SPACING; + } else { + // Empty line - just add spacing + currentY += lineHeight + LINE_SPACING; + } + } + } + + // Draw additional info + + if (!additionalInfo.isEmpty()) { + currentY += LINE_SPACING; + for (String infoLine : additionalInfo) { + List wrappedLines = wrapText(infoLine, wrapWidth); + for (String line : wrappedLines) { + drawText(textX, currentY, line, INFO_COLOR); + currentY += lineHeight + LINE_SPACING; + } + } + } + } + + /** + * Draw colored text segments with word wrapping. + * Returns the Y position after drawing. + */ + private static int drawWrappedSegments(int startX, int startY, int maxWidth, List segments) { + int lineHeight = ClientProxy.Font.height(); + int currentX = startX; + int currentY = startY; + + for (TokenHoverInfo.TextSegment segment : segments) { + String text = segment.text; + int color = 0xFF000000 | segment.color; + + // Split segment into words for wrapping + String[] words = text.split("(?<=\\s)|(?=\\s)"); // Keep whitespace as separate tokens + + for (String word : words) { + int wordWidth = ClientProxy.Font.width(word); + + // Check if word fits on current line + if (currentX + wordWidth > startX + maxWidth && currentX > startX) { + // Wrap to next line + currentY += lineHeight + LINE_SPACING; + currentX = startX; + + // Skip leading whitespace on new line + if (word.trim().isEmpty()) { + continue; + } + } + + drawText(currentX, currentY, word, color); + currentX += wordWidth; + } + } + + return currentY + lineHeight; + } + + /** + * Wrap text to fit within maxWidth. + * Returns list of lines. + */ + private static List wrapText(String text, int maxWidth) { + List lines = new ArrayList<>(); + + if (text == null || text.isEmpty()) { + return lines; + } + + // First split by explicit newlines + String[] paragraphs = text.split("\n", -1); // -1 to preserve trailing empty lines + + for (String paragraph : paragraphs) { + if (paragraph.isEmpty()) { + lines.add(""); + continue; + } + + // Then apply word wrapping to each paragraph + String[] words = paragraph.split(" "); + StringBuilder currentLine = new StringBuilder(); + + for (String word : words) { + String testLine = currentLine.length() == 0 ? word : currentLine + " " + word; + int testWidth = ClientProxy.Font.width(testLine); + + if (testWidth > maxWidth && currentLine.length() > 0) { + lines.add(currentLine.toString()); + currentLine = new StringBuilder(word); + } else { + if (currentLine.length() > 0) { + currentLine.append(" "); + } + currentLine.append(word); + } + } + + if (currentLine.length() > 0) { + lines.add(currentLine.toString()); + } + } + + if (lines.isEmpty()) { + lines.add(text); + } + return lines; + } + + /** + * Draw text using the font renderer. + */ + private static void drawText(int x, int y, String text, int color) { + ClientProxy.Font.drawString(text, x, y, color); + } + + // ==================== DIMENSION CALCULATION ==================== + + /** + * Calculate the width needed for the content, clamped to maxWidth. + * Returns the width of the longest wrapped line. + */ + private static int calculateContentWidth(TokenHoverInfo info, int maxWidth) { + int longestLineWidth = 0; + + // Package name + String packageName = info.getPackageName(); + if (packageName != null && !packageName.isEmpty()) { + String packageText = "\u25CB " + packageName; + List wrappedLines = wrapText(packageText, maxWidth); + for (String line : wrappedLines) { + longestLineWidth = Math.max(longestLineWidth, ClientProxy.Font.width(line)); + } + } + + // Declaration - wrap segments and find longest line + if (!info.getDeclaration().isEmpty()) { + int declarationLongestLine = calculateSegmentsLongestLine(maxWidth, info.getDeclaration()); + longestLineWidth = Math.max(longestLineWidth, declarationLongestLine); + } + + // Errors + for (String error : info.getErrors()) { + List wrappedLines = wrapText(error, maxWidth); + for (String line : wrappedLines) { + longestLineWidth = Math.max(longestLineWidth, ClientProxy.Font.width(line)); + } + } + + // Documentation + for (String doc : info.getDocumentation()) { + List wrappedLines = wrapText(doc, maxWidth); + for (String line : wrappedLines) { + longestLineWidth = Math.max(longestLineWidth, ClientProxy.Font.width(line)); + } + } + + // JSDoc-formatted documentation lines + List jsDocLines = info.getJSDocLines(); + if (jsDocLines != null) { + for (TokenHoverInfo.DocumentationLine docLine : jsDocLines) { + if (!docLine.isEmpty()) { + int lineLongest = calculateSegmentsLongestLine(maxWidth, docLine.segments); + longestLineWidth = Math.max(longestLineWidth, lineLongest); + } + } + } + + // Additional info + for (String line : info.getAdditionalInfo()) { + List wrappedLines = wrapText(line, maxWidth); + for (String wrappedLine : wrappedLines) { + longestLineWidth = Math.max(longestLineWidth, ClientProxy.Font.width(wrappedLine)); + } + } + + // Ensure minimum width + return Math.max(50, longestLineWidth); + } + + /** + * Calculate the height needed for the content given the available width. + * Uses the same wrapping logic as rendering. + */ + private static int calculateContentHeight(TokenHoverInfo info, int contentWidth) { + int lineHeight = ClientProxy.Font.height(); + int totalHeight = 0; + + String packageName = info.getPackageName(); + List declaration = info.getDeclaration(); + List docs = info.getDocumentation(); + List jsDocLines = info.getJSDocLines(); + List additionalInfo = info.getAdditionalInfo(); + + // Errors + List errors = info.getErrors(); + if (!errors.isEmpty()) { + for (String error : errors) { + List wrappedLines = wrapText(error, contentWidth); + totalHeight += wrappedLines.size() * (lineHeight + LINE_SPACING); + } + + boolean onlyErrors = errors != null && !errors.isEmpty() && (packageName == null || packageName.isEmpty()) && (declaration == null || + declaration.isEmpty()) && (docs == null || docs.isEmpty()) && (jsDocLines == null || jsDocLines.isEmpty()) && (additionalInfo == null || additionalInfo.isEmpty()); + + if (!onlyErrors) //Add error separator height + totalHeight += (SEPARATOR_SPACING + SEPARATOR_HEIGHT) * 2 - 5; + + totalHeight += LINE_SPACING; // Extra space after errors + } + + // Package name + if (packageName != null && !packageName.isEmpty()) { + String packageText = "\u25CB " + packageName; + List wrappedLines = wrapText(packageText, contentWidth); + totalHeight += wrappedLines.size() * (lineHeight + LINE_SPACING); + } + + // Declaration (colored segments with wrapping) + if (!declaration.isEmpty()) { + totalHeight += calculateSegmentsHeight(contentWidth, declaration); + totalHeight += LINE_SPACING; + } + + // Documentation (plain text) + if (!docs.isEmpty()) { + // Add space for separator line and spacing + totalHeight += SEPARATOR_SPACING + SEPARATOR_HEIGHT; + for (String doc : docs) { + List wrappedLines = wrapText(doc, contentWidth); + totalHeight += wrappedLines.size() * (lineHeight + LINE_SPACING); + } + } + + // JSDoc-formatted documentation lines + if (jsDocLines != null && !jsDocLines.isEmpty()) { + if (docs.isEmpty() && !declaration.isEmpty()) { + totalHeight += SEPARATOR_HEIGHT + SEPARATOR_SPACING; + } + + for (TokenHoverInfo.DocumentationLine docLine : jsDocLines) { + if (!docLine.isEmpty()) { + totalHeight += calculateSegmentsHeight(contentWidth, docLine.segments); + totalHeight += LINE_SPACING; + } else { + totalHeight += lineHeight + LINE_SPACING; + } + } + } + + // Additional info + if (!additionalInfo.isEmpty()) { + totalHeight += LINE_SPACING; + for (String infoLine : additionalInfo) { + List wrappedLines = wrapText(infoLine, contentWidth); + totalHeight += wrappedLines.size() * (lineHeight + LINE_SPACING); + } + } + + return Math.max(lineHeight, totalHeight); + } + + /** + * Calculate height needed for wrapped segments. + */ + private static int calculateSegmentsHeight(int maxWidth, List segments) { + int lineHeight = ClientProxy.Font.height(); + int currentLineWidth = 0; + int lineCount = 1; + + for (TokenHoverInfo.TextSegment segment : segments) { + String text = segment.text; + String[] words = text.split("(?<=\\s)|(?=\\s)"); + + for (String word : words) { + int wordWidth = ClientProxy.Font.width(word); + + if (currentLineWidth + wordWidth > maxWidth && currentLineWidth > 0) { + lineCount++; + currentLineWidth = word.trim().isEmpty() ? 0 : wordWidth; + } else { + currentLineWidth += wordWidth; + } + } + } + + if (lineCount <= 0) { + return 0; + } + + // Total height = (lines * lineHeight) + (spacing between lines) + return (lineCount * lineHeight) + ((lineCount - 1) * LINE_SPACING); + } + + /** + * Calculate the width of the longest line when segments are wrapped. + */ + private static int calculateSegmentsLongestLine(int maxWidth, List segments) { + int currentLineWidth = 0; + int longestLineWidth = 0; + + for (TokenHoverInfo.TextSegment segment : segments) { + String text = segment.text; + String[] words = text.split("(?<=\\s)|(?=\\s)"); + + for (String word : words) { + int wordWidth = ClientProxy.Font.width(word); + + if (currentLineWidth + wordWidth > maxWidth && currentLineWidth > 0) { + // Line break - record current line width and start new line + longestLineWidth = Math.max(longestLineWidth, currentLineWidth); + currentLineWidth = word.trim().isEmpty() ? 0 : wordWidth; + } else { + currentLineWidth += wordWidth; + } + } + } + + // Don't forget the last line + longestLineWidth = Math.max(longestLineWidth, currentLineWidth); + + return longestLineWidth; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/DTSJSDocParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/DTSJSDocParser.java new file mode 100644 index 000000000..a54e64fe3 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/DTSJSDocParser.java @@ -0,0 +1,216 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DTSJSDocParser { + + private static final Pattern JSDOC_BLOCK_PATTERN = Pattern.compile( + "/\\*\\*\\s*(.*?)\\*/", Pattern.DOTALL); + + private static final Pattern TAG_PATTERN = Pattern.compile( + "@(\\w+)(?:\\s+\\{([^}]+)\\})?(?:\\s+(.*))?"); + + private static final Pattern PARAM_PATTERN = Pattern.compile( + "@param(?:\\s+\\{([^}]+)\\})?(?:\\s+(\\w+))?(?:\\s+(.*))?"); + + private static final Pattern RETURN_PATTERN = Pattern.compile( + "@returns?\\s+(?:\\{([^}]+)\\}\\s*)?(.*)"); + + private static final Pattern TYPE_PATTERN = Pattern.compile( + "@type(?:\\s+\\{([^}]+)\\})?(?:\\s+(.*))?"); + + public static JSDocInfo parseJSDocBlock(String jsDocContent) { + if (jsDocContent == null || jsDocContent.isEmpty()) { + return null; + } + + JSDocInfo info = new JSDocInfo(jsDocContent, -1, -1); + + String[] lines = jsDocContent.split("\\r?\\n"); + StringBuilder descriptionBuilder = new StringBuilder(); + boolean foundFirstTag = false; + JSDocTag lastTag = null; + + for (String line : lines) { + line = cleanLine(line); + if (line.isEmpty()) continue; + + if (line.startsWith("@")) { + foundFirstTag = true; + lastTag = parseTag(line, info); + } else if (!foundFirstTag) { + if (descriptionBuilder.length() > 0) { + descriptionBuilder.append(" "); + } + descriptionBuilder.append(line); + } else if (lastTag != null) { + String existing = lastTag.getDescription(); + if (existing == null || existing.isEmpty()) { + lastTag.setDescription(line); + } else { + lastTag.setDescription(existing + " " + line); + } + } + } + + if (descriptionBuilder.length() > 0) { + info.setDescription(descriptionBuilder.toString().trim()); + } + + return info; + } + + private static String cleanLine(String line) { + line = line.trim(); + if (line.startsWith("/**")) { + line = line.substring(3).trim(); + } + if (line.endsWith("*/")) { + line = line.substring(0, line.length() - 2).trim(); + } + if (line.startsWith("*")) { + line = line.substring(1).trim(); + } + return line; + } + + private static JSDocTag parseTag(String line, JSDocInfo info) { + Matcher paramMatcher = PARAM_PATTERN.matcher(line); + if (paramMatcher.find()) { + String type = paramMatcher.group(1); + String name = paramMatcher.group(2); + String desc = paramMatcher.group(3); + if (desc == null && type == null && name != null) { + desc = name; + name = null; + } + if (desc != null) { + desc = desc.trim(); + if (desc.isEmpty()) { + desc = null; + } + } + JSDocParamTag tag = JSDocParamTag.create(-1, -1, -1, + type, null, -1, -1, name, -1, -1, desc); + info.addParamTag(tag); + return tag; + } + + Matcher returnMatcher = RETURN_PATTERN.matcher(line); + if (returnMatcher.find()) { + String type = returnMatcher.group(1); + String desc = returnMatcher.group(2); + if (desc != null) { + desc = desc.trim(); + if (desc.isEmpty()) { + desc = null; + } + } + JSDocReturnTag tag = JSDocReturnTag.create("return", -1, -1, -1, + type, null, -1, -1, desc); + info.setReturnTag(tag); + return tag; + } + + Matcher typeMatcher = TYPE_PATTERN.matcher(line); + if (typeMatcher.find()) { + String type = typeMatcher.group(1); + String desc = typeMatcher.group(2); + if (desc != null) { + desc = desc.trim(); + if (desc.isEmpty()) { + desc = null; + } + } + JSDocTypeTag tag = JSDocTypeTag.create(-1, -1, -1, + type, null, -1, -1, desc); + info.setTypeTag(tag); + return tag; + } + + Matcher tagMatcher = TAG_PATTERN.matcher(line); + if (tagMatcher.find()) { + String tagName = tagMatcher.group(1); + String typeName = tagMatcher.group(2); + String rest = tagMatcher.group(3); + + switch (tagName) { + case "see": + JSDocSeeTag seeTag = JSDocSeeTag.createSimple(rest != null ? rest.trim() : ""); + info.addSeeTag(seeTag); + return seeTag; + default: + JSDocTag genericTag = new JSDocTag(tagName, -1, -1, -1); + if (typeName != null) { + typeName = typeName.trim(); + genericTag.setType(typeName, null, -1, -1); + } + genericTag.setDescription(rest != null ? rest.trim() : ""); + info.addTag(genericTag); + return genericTag; + } + } + return null; + } + + public static String extractJSDocBefore(String content, int elementStart) { + if (elementStart <= 0) return null; + + int searchStart = Math.max(0, elementStart - 2000); + String searchArea = content.substring(searchStart, elementStart); + + int lastJSDocEnd = searchArea.lastIndexOf("*/"); + if (lastJSDocEnd < 0) return null; + + int jsDocStart = searchArea.lastIndexOf("/**", lastJSDocEnd); + if (jsDocStart < 0) return null; + + String between = searchArea.substring(lastJSDocEnd + 2).trim(); + if (between.isEmpty() || isOnlyWhitespaceOrModifiers(between)) { + return searchArea.substring(jsDocStart, lastJSDocEnd + 2); + } + + return null; + } + + private static boolean isOnlyWhitespaceOrModifiers(String text) { + String cleaned = text.replaceAll("\\s+", " ").trim(); + return cleaned.isEmpty() + || cleaned.matches("^(export\\s*)?(readonly\\s*)?(interface|class|type|function)?\\s*$"); + } + + public static List extractAllJSDocBlocks(String content) { + List blocks = new ArrayList<>(); + Matcher matcher = JSDOC_BLOCK_PATTERN.matcher(content); + + while (matcher.find()) { + int start = matcher.start(); + int end = matcher.end(); + String docContent = matcher.group(0); + blocks.add(new JSDocBlock(start, end, docContent)); + } + + return blocks; + } + + public static class JSDocBlock { + public final int start; + public final int end; + public final String content; + + public JSDocBlock(int start, int end, String content) { + this.start = start; + this.end = end; + this.content = content; + } + + public JSDocInfo parse() { + return parseJSDocBlock(content); + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/DtsModScanner.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/DtsModScanner.java new file mode 100644 index 000000000..f8aa926c0 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/DtsModScanner.java @@ -0,0 +1,197 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import cpw.mods.fml.common.Loader; +import cpw.mods.fml.common.ModContainer; + +import java.io.*; +import java.util.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class DtsModScanner { + + private static final Pattern MOD_DTS_PATH_PATTERN = Pattern.compile("^assets/([^/]+)/api/(.+\\.d\\.ts)$"); + + private static final List DOMAIN_PRIORITY = Arrays.asList("customnpcs", "npcdbc"); + + private DtsModScanner() {} + + static List collectDtsFilesFromMods() { + List dtsFiles = new ArrayList<>(); + for (ModContainer mod : Loader.instance().getModList()) { + File source = mod.getSource(); + if (source == null || !source.exists()) { + continue; + } + if (source.isDirectory()) { + scanDirectoryForModDts(source, mod.getModId(), dtsFiles); + } else if (source.getName().endsWith(".jar") || source.getName().endsWith(".zip")) { + scanJarForModDts(source, mod.getModId(), dtsFiles); + } + } + return dtsFiles; + } + + static void sortDtsFiles(List dtsFiles) { + Collections.sort(dtsFiles, new DtsFileRefComparator()); + } + + static void logSummary(List dtsFiles) { + Map domainCounts = new LinkedHashMap<>(); + for (DtsFileRef ref : dtsFiles) { + domainCounts.put(ref.getDomain(), domainCounts.getOrDefault(ref.getDomain(), 0) + 1); + } + System.out.println("[JSTypeRegistry] Found " + dtsFiles.size() + " .d.ts files across " + domainCounts.size() + " domains"); + for (Map.Entry entry : domainCounts.entrySet()) { + System.out.println("[JSTypeRegistry] Domain " + entry.getKey() + ": " + entry.getValue() + " files"); + } + } + + private static void scanDirectoryForModDts(File baseDir, String modId, List dtsFiles) { + scanDirectoryForModDts(baseDir, baseDir, modId, dtsFiles); + } + + private static void scanDirectoryForModDts(File baseDir, File directory, String modId, List dtsFiles) { + File[] files = directory.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.isDirectory()) { + scanDirectoryForModDts(baseDir, file, modId, dtsFiles); + continue; + } + if (!file.getName().endsWith(".d.ts")) { + continue; + } + String relativePath = baseDir.toURI().relativize(file.toURI()).getPath(); + if (relativePath == null) { + continue; + } + String normalized = relativePath.replace('\\', '/'); + Matcher matcher = MOD_DTS_PATH_PATTERN.matcher(normalized); + if (matcher.matches()) { + String domain = matcher.group(1); + String apiPath = matcher.group(2); + dtsFiles.add(DtsFileRef.forFile(modId, domain, apiPath, file)); + } + } + } + + private static void scanJarForModDts(File jarFile, String modId, List dtsFiles) { + try { + JarFile jar = new JarFile(jarFile); + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String entryName = entry.getName(); + Matcher matcher = MOD_DTS_PATH_PATTERN.matcher(entryName); + if (matcher.matches()) { + String domain = matcher.group(1); + String apiPath = matcher.group(2); + dtsFiles.add(DtsFileRef.forJar(modId, domain, apiPath, jarFile, entryName)); + } + } + jar.close(); + } catch (Exception e) { + System.err.println("[JSTypeRegistry] Error scanning mod jar for .d.ts files: " + e.getMessage()); + } + } + + static final class DtsFileRef { + private final String modId; + private final String domain; + private final String relativePath; + private final File file; + private final File jarFile; + private final String jarEntryName; + + private DtsFileRef(String modId, String domain, String relativePath, File file, File jarFile, String jarEntryName) { + this.modId = modId; + this.domain = domain; + this.relativePath = relativePath; + this.file = file; + this.jarFile = jarFile; + this.jarEntryName = jarEntryName; + } + + static DtsFileRef forFile(String modId, String domain, String relativePath, File file) { + return new DtsFileRef(modId, domain, relativePath, file, null, null); + } + + static DtsFileRef forJar(String modId, String domain, String relativePath, File jarFile, String jarEntryName) { + return new DtsFileRef(modId, domain, relativePath, null, jarFile, jarEntryName); + } + + InputStream openStream() throws IOException { + if (file != null) { + return new FileInputStream(file); + } + if (jarFile != null && jarEntryName != null) { + final JarFile jar = new JarFile(jarFile); + JarEntry entry = jar.getJarEntry(jarEntryName); + if (entry == null) { + jar.close(); + return null; + } + InputStream is = jar.getInputStream(entry); + return new FilterInputStream(is) { + @Override + public void close() throws IOException { + super.close(); + jar.close(); + } + }; + } + return null; + } + + String getDomain() { + return domain; + } + + String getRelativePath() { + return relativePath; + } + + String getOrigin() { + return modId + ":" + domain + ":" + relativePath; + } + } + + private static class DtsFileRefComparator implements Comparator { + @Override + public int compare(DtsFileRef a, DtsFileRef b) { + int domainCompare = Integer.compare(getDomainRank(a.getDomain()), getDomainRank(b.getDomain())); + if (domainCompare != 0) { + return domainCompare; + } + int domainNameCompare = a.getDomain().compareTo(b.getDomain()); + if (domainNameCompare != 0) { + return domainNameCompare; + } + int filePriorityCompare = Integer.compare(getFilePriority(a.getRelativePath()), getFilePriority(b.getRelativePath())); + if (filePriorityCompare != 0) { + return filePriorityCompare; + } + return a.getRelativePath().compareTo(b.getRelativePath()); + } + + private int getDomainRank(String domain) { + int idx = DOMAIN_PRIORITY.indexOf(domain); + return idx >= 0 ? idx : DOMAIN_PRIORITY.size(); + } + + private int getFilePriority(String relativePath) { + if (relativePath.endsWith("hooks.d.ts")) { + return 0; + } + if (relativePath.endsWith("index.d.ts")) { + return 1; + } + return 2; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSFieldInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSFieldInfo.java new file mode 100644 index 000000000..7cec4dd6c --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSFieldInfo.java @@ -0,0 +1,112 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.GenericContext; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; +/** + * Represents a field/property in a TypeScript interface. + */ +public class JSFieldInfo { + + private final String name; + private final String type; // Raw type string for display + private TypeInfo typeInfo; // Resolved TypeInfo + private final boolean readonly; + private JSDocInfo jsDocInfo; + private JSTypeInfo containingType; // The type that contains this field + + public JSFieldInfo(String name, String type, boolean readonly) { + this.name = name; + this.type = type; + this.readonly = readonly; + } + + public JSFieldInfo setJsDocInfo(JSDocInfo jsDocInfo) { + this.jsDocInfo = jsDocInfo; + return this; + } + + + /** + * Set the containing type (the JSTypeInfo that owns this field). + */ + public void setContainingType(JSTypeInfo containingType) { + this.containingType = containingType; + } + + public void setTypeInfo(TypeInfo typeInfo) { + this.typeInfo = typeInfo; + } + + /** + * Get the resolved type, with fallback resolution using type parameters. + * @param contextType The TypeInfo context for resolving type parameters + * @return The resolved TypeInfo for the field type + */ + public TypeInfo getResolvedType(TypeInfo contextType) { + TypeResolver resolver = TypeResolver.getInstance(); + + // Cache only the raw resolved type; substitutions depend on the receiver context. + TypeInfo resolved = typeInfo != null ? typeInfo : resolver.resolveJSType(type); + if (typeInfo == null) { + typeInfo = resolved; + } + + if (contextType != null) { + GenericContext ctx = GenericContext.forReceiver(contextType); + return ctx.substituteType(resolved, type, resolver); + } + + return resolved; + } + + // Getters + public String getName() { return name; } + public String getType() { return type; } + public TypeInfo getTypeInfo() { return typeInfo; } + public boolean isReadonly() { return readonly; } + public JSDocInfo getJsDocInfo() { return jsDocInfo; } + public JSTypeInfo getContainingType() { return containingType; } + + /** + * Get documentation string (backward compatibility). + * Extracts description from JSDocInfo if available. + */ + public String getDocumentation() { + return jsDocInfo != null ? jsDocInfo.getDescription() : null; + } + + /** + * Get display name - uses resolved TypeInfo display name if available, + * including generic type arguments like List. + */ + public String getDisplayType() { + if (typeInfo != null && typeInfo.isResolved()) { + return typeInfo.getDisplayName(); + } + return type; + } + + /** + * Build hover info HTML for this field. + */ + public String buildHoverInfo() { + StringBuilder sb = new StringBuilder(); + if (readonly) { + sb.append("readonly "); + } + sb.append("").append(name).append(": ").append(type).append(""); + + if (jsDocInfo != null && jsDocInfo.getDescription() != null && !jsDocInfo.getDescription().isEmpty()) { + sb.append("

").append(jsDocInfo.getDescription()); + } + + return sb.toString(); + } + + @Override + public String toString() { + return (readonly ? "readonly " : "") + name + ": " + type; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSMethodInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSMethodInfo.java new file mode 100644 index 000000000..bfb783849 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSMethodInfo.java @@ -0,0 +1,193 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.GenericContext; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.util.*; + +/** + * Represents a method signature parsed from .d.ts files. + */ +public class JSMethodInfo { + + private final String name; + private final String returnType; // Raw type string for display, e.g., "number", "IEntity", "void" + private TypeInfo returnTypeInfo; // Resolved TypeInfo (set during Phase 2) + private final List parameters; + private JSDocInfo jsDocInfo; + private JSTypeInfo containingType; // The type that contains this method + + public JSMethodInfo(String name, String returnType, List parameters) { + this.name = name; + this.returnType = returnType; + this.parameters = parameters != null ? new ArrayList<>(parameters) : new ArrayList<>(); + } + + public JSMethodInfo setJsDocInfo(JSDocInfo jsDocInfo) { + this.jsDocInfo = jsDocInfo; + return this; + } + + /** + * Set the resolved return type info (called during Phase 2 type resolution). + */ + public void setReturnTypeInfo(TypeInfo typeInfo) { + this.returnTypeInfo = typeInfo; + } + + /** + * Set the containing type (the JSTypeInfo that owns this method). + */ + public void setContainingType(JSTypeInfo containingType) { + this.containingType = containingType; + // Also set for all parameters + for (JSParameterInfo param : parameters) + param.setContainingMethod(this); + } + + /** + * Get the resolved return type, with type parameter substitution based on receiver context. + * + * For parameterized receivers like List, this will substitute type variables: + * - If return type is "T" and receiver is List, returns TypeInfo(String) + * - Handles nested generics like List> with proper substitution + * + * @param contextType The TypeInfo context for resolving type parameters (e.g., List to resolve E → String) + * @return The resolved TypeInfo for the return type, with type variables substituted + */ + public TypeInfo getResolvedReturnType(TypeInfo contextType) { + TypeResolver resolver = TypeResolver.getInstance(); + TypeInfo resolved = resolver.resolveJSType(returnType); + + if (contextType != null) { + GenericContext ctx = GenericContext.forReceiver(contextType); + resolved = ctx.substituteType(resolved, returnType, resolver); + } + + return resolved; + } + + // Getters + public String getName() { return name; } + public String getReturnType() { return returnType; } + public TypeInfo getReturnTypeInfo() { return returnTypeInfo; } + public List getParameters() { return parameters; } + public JSDocInfo getJsDocInfo() { return jsDocInfo; } + public int getParameterCount() { return parameters.size(); } + public JSTypeInfo getContainingType() { return containingType; } + + /** + * Get documentation string (backward compatibility). + * Extracts description from JSDocInfo if available. + */ + public String getDocumentation() { + return jsDocInfo != null ? jsDocInfo.getDescription() : null; + } + + /** + * Get a formatted signature string for display. + */ + public String getSignature() { + StringBuilder sb = new StringBuilder(); + sb.append(name).append("("); + for (int i = 0; i < parameters.size(); i++) { + if (i > 0) sb.append(", "); + JSParameterInfo param = parameters.get(i); + sb.append(param.getName()).append(": ").append(param.getType()); + } + sb.append("): ").append(returnType); + return sb.toString(); + } + + /** + * Build hover info HTML for this method. + */ + public String buildHoverInfo() { + StringBuilder sb = new StringBuilder(); + sb.append("").append(name).append("("); + for (int i = 0; i < parameters.size(); i++) { + if (i > 0) sb.append(", "); + JSParameterInfo param = parameters.get(i); + sb.append(param.getName()).append(": ").append(param.getType()).append(""); + } + sb.append("): ").append(returnType).append(""); + + if (jsDocInfo != null && jsDocInfo.getDescription() != null && !jsDocInfo.getDescription().isEmpty()) { + sb.append("

").append(jsDocInfo.getDescription()); + } + + return sb.toString(); + } + + @Override + public String toString() { + return getSignature(); + } + + /** + * Represents a parameter in a method signature. + */ + public static class JSParameterInfo { + private final String name; + private final String type; + private TypeInfo typeInfo; // Resolved TypeInfo (set during Phase 2) + private JSMethodInfo containingMethod; // The type that contains the method with this parameter + + public JSParameterInfo(String name, String type) { + this.name = name; + this.type = type; + } + + public void setContainingMethod(JSMethodInfo containingMethod) { + this.containingMethod = containingMethod; + } + + public void setTypeInfo(TypeInfo typeInfo) { + this.typeInfo = typeInfo; + } + + /** + * Get the resolved type, with type parameter substitution based on receiver context. + * + * For parameterized receivers like Consumer, this will substitute type variables: + * - If param type is "T" and receiver is Consumer, returns TypeInfo(IAction) + * + * @param contextType The TypeInfo context for resolving type parameters + * @return The resolved TypeInfo for the parameter type + */ + public TypeInfo getResolvedType(TypeInfo contextType) { + TypeResolver resolver = TypeResolver.getInstance(); + TypeInfo resolved = resolver.resolveJSType(type); + + if (contextType != null) { + GenericContext ctx = GenericContext.forReceiver(contextType); + resolved = ctx.substituteType(resolved, type, resolver); + } + + return resolved; + } + + public String getName() { return name; } + public String getType() { return type; } + public TypeInfo getTypeInfo() {return typeInfo;} + public JSMethodInfo getContainingMethod() {return containingMethod;} + + /** + * Get display name - uses resolved TypeInfo display name if available, + * including generic type arguments like List. + */ + public String getDisplayType() { + if (typeInfo != null && typeInfo.isResolved()) { + return typeInfo.getDisplayName(); + } + return type; + } + + @Override + public String toString() { + return name + ": " + type; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSScriptAnalyzer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSScriptAnalyzer.java new file mode 100644 index 000000000..0ebcce69f --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSScriptAnalyzer.java @@ -0,0 +1,554 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.ScriptLine; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import scala.annotation.meta.field; + +import java.util.*; +import java.util.regex.*; + +/** + * Analyzes JavaScript scripts to produce syntax highlighting marks and type information. + * Uses the JSTypeRegistry to resolve types and validate member access. + * + * @deprecated This class is deprecated. All JavaScript analysis is now handled by the + * unified pipeline in {@link ScriptDocument}. JavaScript and Java scripts now use the + * same methods and data structures: + *

    + *
  • {@link ScriptDocument#parseMethodDeclarations()} - for both Java methods and JS functions
  • + *
  • {@link ScriptDocument#parseLocalVariables()} - for both Java and JS local variables
  • + *
  • {@link ScriptDocument#parseGlobalFields()} - for both Java and JS global variables
  • + *
  • {@link ScriptDocument#buildMarks(List)} - for mark building (both languages)
  • + *
+ * Type information is available via the unified data structures: {@code getMethods()}, + * {@code getMethodLocals()}, and {@code getGlobalFields()}. + */ +@Deprecated +public class JSScriptAnalyzer { + + private final ScriptDocument document; + private final String text; + private final JSTypeRegistry registry; + + // Variable tracking: varName -> inferred type name + private final Map variableTypes = new HashMap<>(); + + // Function parameter types: funcName -> paramName -> typeName + private final Map> functionParams = new HashMap<>(); + + // Excluded ranges (strings, comments) + private final List excludedRanges = new ArrayList<>(); + + // Patterns + private static final Pattern STRING_PATTERN = Pattern.compile("\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'"); + private static final Pattern COMMENT_PATTERN = Pattern.compile("//.*?$|/\\*.*?\\*/", Pattern.MULTILINE | Pattern.DOTALL); + private static final Pattern FUNCTION_PATTERN = Pattern.compile("function\\s+(\\w+)\\s*\\(([^)]*)\\)"); + private static final Pattern VAR_DECL_PATTERN = Pattern.compile("(?:var|let|const)\\s+(\\w+)(?:\\s*=\\s*([^;]+))?"); + private static final Pattern ASSIGNMENT_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*([^;]+)"); + private static final Pattern METHOD_CALL_PATTERN = Pattern.compile("(\\w+(?:\\.\\w+)*)\\s*\\("); + private static final Pattern MEMBER_ACCESS_PATTERN = Pattern.compile("(\\w+)(?:\\.(\\w+))+"); + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("\\b([a-zA-Z_][a-zA-Z0-9_]*)\\b"); + + private static final Pattern JS_KEYWORD_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|" + + "class|extends|import|export|default|async|await|yield)\\b"); + + private static final Pattern NUMBER_PATTERN = Pattern.compile("\\b\\d+\\.?\\d*\\b"); + + public JSScriptAnalyzer(ScriptDocument document) { + this.document = document; + this.text = document.getText(); + this.registry = JSTypeRegistry.getInstance(); + // Ensure registry is initialized + if (!registry.isInitialized()) { + registry.initializeFromResources(); + } + } + + /** + * Analyze the script and produce marks. + */ + public List analyze() { + List marks = new ArrayList<>(); + + // First pass: find excluded regions + findExcludedRanges(); + + // Mark comments and strings + addPatternMarks(marks, COMMENT_PATTERN, TokenType.COMMENT); + addPatternMarks(marks, STRING_PATTERN, TokenType.STRING); + + // Mark keywords + addPatternMarks(marks, JS_KEYWORD_PATTERN, TokenType.KEYWORD); + + // Mark numbers + addPatternMarks(marks, NUMBER_PATTERN, TokenType.LITERAL); + + // Parse functions and infer parameter types from hooks + parseFunctions(marks); + + // Parse variable declarations + parseVariables(marks); + + // Mark member accesses with type validation + markMemberAccesses(marks); + + // Mark method calls + markMethodCalls(marks); + + // Mark standalone identifiers (parameters and variables in function bodies) + markIdentifiers(marks); + + return marks; + } + + /** + * Find strings and comments to exclude from analysis. + */ + private void findExcludedRanges() { + Matcher m = STRING_PATTERN.matcher(text); + while (m.find()) { + excludedRanges.add(new int[]{m.start(), m.end()}); + } + + m = COMMENT_PATTERN.matcher(text); + while (m.find()) { + excludedRanges.add(new int[]{m.start(), m.end()}); + } + } + + private boolean isExcluded(int pos) { + for (int[] range : excludedRanges) { + if (pos >= range[0] && pos < range[1]) return true; + } + return false; + } + + /** + * Parse function declarations and infer parameter types from hook signatures. + */ + private void parseFunctions(List marks) { + Matcher m = FUNCTION_PATTERN.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) continue; + + String funcName = m.group(1); + String params = m.group(2); + + // Mark function name + int nameStart = m.start(1); + int nameEnd = m.end(1); + + // Check if this is a known hook + if (registry.isHook(funcName)) { + // Create a unified MethodInfo for the hook + List sigs = registry.getHookSignatures(funcName); + if (!sigs.isEmpty()) { + JSTypeRegistry.HookSignature sig = sigs.get(0); + + // Create unified MethodInfo for hover info + TypeInfo paramTypeInfo = resolveJSType(sig.paramType); + List methodParams = new ArrayList<>(); + methodParams.add(FieldInfo.reflectionParam(sig.paramName, paramTypeInfo)); + + MethodInfo hookMethod = MethodInfo.declaration( + funcName, null, TypeInfo.fromPrimitive("void"), methodParams, + nameStart, nameStart, nameStart, -1, -1, 0, sig.doc + ); + + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_DECL, hookMethod)); + + // Infer parameter types from hook + if (!params.isEmpty()) { + // Parse parameter names from function + String[] paramNames = params.split(","); + if (paramNames.length > 0) { + String paramName = paramNames[0].trim(); + String paramType = sig.paramType; + + // Store in function params and variable types + Map funcParamMap = new HashMap<>(); + funcParamMap.put(paramName, paramType); + functionParams.put(funcName, funcParamMap); + variableTypes.put(paramName, paramType); + + // Mark the parameter with unified FieldInfo + int paramStart = m.start(2) + params.indexOf(paramName); + int paramEnd = paramStart + paramName.length(); + FieldInfo paramFieldInfo = FieldInfo.parameter( + paramName, paramTypeInfo, paramStart, hookMethod + ); + marks.add(new ScriptLine.Mark(paramStart, paramEnd, TokenType.PARAMETER, paramFieldInfo)); + } + } + } else { + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_DECL, funcName)); + } + } else { + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_DECL, funcName)); + } + } + } + + /** + * Parse variable declarations and infer types. + */ + private void parseVariables(List marks) { + Matcher m = VAR_DECL_PATTERN.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) continue; + + String varName = m.group(1); + String initializer = m.group(2); + + // Infer type from initializer + String inferredType = null; + if (initializer != null) { + inferredType = inferTypeFromExpression(initializer.trim()); + } + + if (inferredType != null) { + variableTypes.put(varName, inferredType); + } + + // Mark variable declaration with unified FieldInfo + int varStart = m.start(1); + int varEnd = m.end(1); + TypeInfo typeInfo = resolveJSType(inferredType != null ? inferredType : "any"); + FieldInfo varFieldInfo = FieldInfo.localField(varName, typeInfo, varStart, null); + marks.add(new ScriptLine.Mark(varStart, varEnd, TokenType.LOCAL_FIELD, varFieldInfo)); + } + } + + /** + * Infer type from an expression. + */ + private String inferTypeFromExpression(String expr) { + if (expr == null || expr.isEmpty()) return null; + + // String literal + if (expr.startsWith("\"") || expr.startsWith("'")) { + return "string"; + } + + // Number literal + if (expr.matches("\\d+\\.?\\d*")) { + return "number"; + } + + // Boolean literal + if (expr.equals("true") || expr.equals("false")) { + return "boolean"; + } + + // null/undefined + if (expr.equals("null")) return "null"; + if (expr.equals("undefined")) return "undefined"; + + // Array literal + if (expr.startsWith("[")) { + return "any[]"; + } + + // Object literal + if (expr.startsWith("{")) { + return "object"; + } + + // Method call: something.method() - infer from method return type + if (expr.contains(".") && expr.contains("(")) { + return inferTypeFromMethodCall(expr); + } + + // Variable reference + if (variableTypes.containsKey(expr)) { + return variableTypes.get(expr); + } + + return null; + } + + /** + * Infer type from a method call chain. + */ + private String inferTypeFromMethodCall(String expr) { + // Remove trailing parentheses and args for analysis + int parenIndex = expr.indexOf('('); + if (parenIndex > 0) { + expr = expr.substring(0, parenIndex); + } + + String[] parts = expr.split("\\."); + if (parts.length < 2) return null; + + // Start with the receiver type + String currentType = variableTypes.get(parts[0]); + if (currentType == null) return null; + + // Walk the chain + for (int i = 1; i < parts.length; i++) { + JSTypeInfo typeInfo = registry.getType(currentType); + if (typeInfo == null) return null; + + String member = parts[i]; + + // Check if it's a method + JSMethodInfo method = typeInfo.getMethod(member); + if (method != null) { + currentType = method.getReturnType(); + continue; + } + + // Check if it's a field + JSFieldInfo f = typeInfo.getField(member); + if (f != null) { + currentType = f.getType(); + continue; + } + + return null; // Unknown member + } + + return currentType; + } + + /** + * Mark member accesses (x.y.z) with type validation. + */ + private void markMemberAccesses(List marks) { + Matcher m = MEMBER_ACCESS_PATTERN.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) continue; + + String fullAccess = m.group(0); + String[] parts = fullAccess.split("\\."); + + if (parts.length < 2) continue; + + // Get receiver type + String receiverName = parts[0]; + String currentType = variableTypes.get(receiverName); + + int pos = m.start(); + + // Mark receiver with unified FieldInfo + if (currentType != null) { + TypeInfo unifiedType = resolveJSType(currentType); + FieldInfo receiverField = FieldInfo.localField(receiverName, unifiedType, pos, null); + marks.add(new ScriptLine.Mark(pos, pos + receiverName.length(), TokenType.LOCAL_FIELD, receiverField)); + } + + pos += receiverName.length() + 1; // +1 for the dot + + // Walk the chain and mark each member + for (int i = 1; i < parts.length; i++) { + String member = parts[i]; + int memberStart = pos; + int memberEnd = pos + member.length(); + + if (currentType != null) { + JSTypeInfo jsTypeInfo = registry.getType(currentType); + if (jsTypeInfo != null) { + TypeInfo unifiedContainingType = TypeInfo.fromJSTypeInfo(jsTypeInfo); + + // Check method first + JSMethodInfo jsMethod = jsTypeInfo.getMethod(member); + if (jsMethod != null) { + // Convert to unified MethodInfo + MethodInfo unifiedMethod = MethodInfo.fromJSMethod(jsMethod, unifiedContainingType); + marks.add(new ScriptLine.Mark(memberStart, memberEnd, TokenType.METHOD_CALL, unifiedMethod)); + currentType = jsMethod.getReturnType(); + } else { + // Check field + JSFieldInfo jsField = jsTypeInfo.getField(member); + if (jsField != null) { + // Convert to unified FieldInfo + FieldInfo unifiedField = FieldInfo.fromJSField(jsField, unifiedContainingType); + marks.add(new ScriptLine.Mark(memberStart, memberEnd, TokenType.GLOBAL_FIELD, unifiedField)); + currentType = jsField.getType(); + } else { + // Unknown member - mark as undefined + marks.add(new ScriptLine.Mark(memberStart, memberEnd, TokenType.UNDEFINED_VAR, + "Unknown member '" + member + "' on type " + jsTypeInfo.getFullName())); + currentType = null; + } + } + } else { + currentType = null; + } + } + + pos = memberEnd + 1; // +1 for the next dot + } + } + } + + /** + * Mark method calls. + */ + private void markMethodCalls(List marks) { + Matcher m = METHOD_CALL_PATTERN.matcher(text); + while (m.find()) { + if (isExcluded(m.start())) continue; + + String callExpr = m.group(1); + if (!callExpr.contains(".")) { + // Simple function call + int nameStart = m.start(1); + int nameEnd = m.end(1); + + if (registry.isHook(callExpr)) { + // It's a known hook being called - create unified MethodInfo + List sigs = registry.getHookSignatures(callExpr); + if (!sigs.isEmpty()) { + JSTypeRegistry.HookSignature sig = sigs.get(0); + TypeInfo paramTypeInfo = resolveJSType(sig.paramType); + List methodParams = new ArrayList<>(); + methodParams.add(FieldInfo.reflectionParam(sig.paramName, paramTypeInfo)); + + MethodInfo hookMethod = MethodInfo.declaration( + callExpr, null, TypeInfo.fromPrimitive("void"), methodParams, + nameStart, nameStart, nameStart, -1, -1, 0, sig.doc + ); + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.METHOD_CALL, hookMethod)); + } + } + } + // Chained calls are handled in markMemberAccesses + } + } + + /** + * Add pattern-based marks. + */ + private void addPatternMarks(List marks, Pattern pattern, TokenType type) { + Matcher m = pattern.matcher(text); + while (m.find()) { + marks.add(new ScriptLine.Mark(m.start(), m.end(), type)); + } + } + + /** + * Get the inferred type of a variable at a position. + */ + public String getVariableType(String varName) { + return variableTypes.get(varName); + } + + /** + * Get all inferred variable types. + */ + public Map getVariableTypes() { + return new HashMap<>(variableTypes); + } + + /** + * Resolves a JavaScript type name to a unified TypeInfo. + * Handles primitives, mapped types, and custom types from the registry. + */ + private TypeInfo resolveJSType(String jsTypeName) { + if (jsTypeName == null || jsTypeName.isEmpty() || "void".equals(jsTypeName)) { + return TypeInfo.fromPrimitive("void"); + } + + // Handle JS primitives + switch (jsTypeName) { + case "string": + return TypeInfo.fromClass(String.class); + case "number": + return TypeInfo.fromClass(double.class); + case "boolean": + return TypeInfo.fromClass(boolean.class); + case "any": + return TypeInfo.fromClass(Object.class); + case "void": + return TypeInfo.fromPrimitive("void"); + } + + // Handle array types + if (jsTypeName.endsWith("[]")) { + String elementType = jsTypeName.substring(0, jsTypeName.length() - 2); + TypeInfo elementTypeInfo = resolveJSType(elementType); + return TypeInfo.arrayOf(elementTypeInfo); + } + + // Try to resolve from the JS type registry + if (registry != null) { + JSTypeInfo jsTypeInfo = registry.getType(jsTypeName); + if (jsTypeInfo != null) { + return TypeInfo.fromJSTypeInfo(jsTypeInfo); + } + } + + // Fallback: unresolved type + return TypeInfo.unresolved(jsTypeName, jsTypeName); + } + + /** + * Mark all identifiers in the script - variables and parameters. + * This ensures consistent coloring for parameters throughout function bodies. + */ + private void markIdentifiers(List marks) { + // Build set of positions already marked + Set markedPositions = new HashSet<>(); + for (ScriptLine.Mark mark : marks) { + for (int i = mark.start; i < mark.end; i++) { + markedPositions.add(i); + } + } + + // Find all identifiers + Matcher matcher = IDENTIFIER_PATTERN.matcher(text); + while (matcher.find()) { + if (isExcluded(matcher.start())) { + continue; + } + + int start = matcher.start(); + int end = matcher.end(); + + // Skip if already marked + boolean alreadyMarked = false; + for (int i = start; i < end; i++) { + if (markedPositions.contains(i)) { + alreadyMarked = true; + break; + } + } + if (alreadyMarked) { + continue; + } + + String identifier = matcher.group(); + + // Check if it's a parameter + boolean isParameter = false; + for (Map params : functionParams.values()) { + if (params.containsKey(identifier)) { + isParameter = true; + break; + } + } + + if (isParameter) { + marks.add(new ScriptLine.Mark(start, end, TokenType.PARAMETER)); + // Mark this position as used + for (int i = start; i < end; i++) { + markedPositions.add(i); + } + } else if (variableTypes.containsKey(identifier)) { + // It's a local variable + marks.add(new ScriptLine.Mark(start, end, TokenType.LOCAL_FIELD)); + // Mark this position as used + for (int i = start; i < end; i++) { + markedPositions.add(i); + } + } + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSTypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSTypeInfo.java new file mode 100644 index 000000000..0926ceafe --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSTypeInfo.java @@ -0,0 +1,311 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocParamTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocReturnTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocTypeTag; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.util.*; + +/** + * Represents a TypeScript interface/type parsed from .d.ts files. + * This is the JS equivalent of TypeInfo for Java types. + */ +public class JSTypeInfo { + + private final String simpleName; // e.g., "InteractEvent" + private final String fullName; // e.g., "IPlayerEvent.InteractEvent" + private final String namespace; // e.g., "IPlayerEvent" (parent namespace, null for top-level) + + // Java fully-qualified name (e.g., "noppes.npcs.api.entity.IPlayer") + private String javaFqn; + + // Type parameters (generics) + private final List typeParams = new ArrayList<>(); + + // Members + private final Map methods = new LinkedHashMap<>(); + private final Map fields = new LinkedHashMap<>(); + + // Inheritance + private String extendsType; // The type this interface extends + private JSTypeInfo resolvedParent; // Resolved parent type (after registry is built) + + // Inner types (for namespaces) + private final Map innerTypes = new LinkedHashMap<>(); + private JSTypeInfo parentType; // The containing type (for inner types) + + // Documentation + private JSDocInfo jsDocInfo; + + public JSTypeInfo(String simpleName, String namespace) { + this.simpleName = simpleName; + this.namespace = namespace; + this.fullName = namespace != null ? namespace + "." + simpleName : simpleName; + } + + // Builder methods + public JSTypeInfo setExtends(String extendsType) { + this.extendsType = extendsType; + return this; + } + + public JSTypeInfo setJsDocInfo(JSDocInfo jsDocInfo) { + this.jsDocInfo = jsDocInfo; + return this; + } + + public JSTypeInfo setJavaFqn(String javaFqn) { + this.javaFqn = javaFqn; + return this; + } + + public void addMethod(JSMethodInfo method) { + method.setContainingType(this); + // Handle overloads - store with index if name already exists + String key = method.getName(); + if (methods.containsKey(key)) { + // Find next available key for overload + int index = 1; + while (methods.containsKey(key + "$" + index)) { + index++; + } + methods.put(key + "$" + index, method); + } else { + methods.put(key, method); + } + } + + public void addField(JSFieldInfo field) { + field.setContainingType(this); + fields.put(field.getName(), field); + } + + public void addInnerType(JSTypeInfo inner) { + inner.parentType = this; + innerTypes.put(inner.getSimpleName(), inner); + } + + public void setResolvedParent(JSTypeInfo parent) { + this.resolvedParent = parent; + } + + // Getters + public String getSimpleName() { return simpleName; } + public String getFullName() { return fullName; } + public String getNamespace() { return namespace; } + public String getJavaFqn() { return javaFqn; } + public String getExtendsType() { return extendsType; } + public JSTypeInfo getResolvedParent() { return resolvedParent; } + public JSDocInfo getJsDocInfo() { return jsDocInfo; } + public JSTypeInfo getParentType() { return parentType; } + public List getTypeParams() { return typeParams; } + + public Map getMethods() { return methods; } + public Map getFields() { return fields; } + public Map getInnerTypes() { return innerTypes; } + + /** + * Get the type parameter info for a given parameter name (e.g., "T"). + * @return TypeParamInfo or null if not found + */ + public TypeParamInfo getTypeParam(String name) { + for (TypeParamInfo param : typeParams) { + if (param.getName().equals(name)) { + return param; + } + } + return null; + } + + public void addTypeParam(TypeParamInfo param) { + typeParams.add(param); + } + + /** + * Resolve all type parameters for this type. + * Called during Phase 2 after all types are loaded into the registry. + */ + public void resolveTypeParameters() { + for (TypeParamInfo param : typeParams) { + param.resolveBoundType(); + } + } + + /** + * Resolve all member types (return types, field types, parameter types). + * Called during Phase 2 after all types are loaded into the registry. + * This resolves types like "Java.java.io.File" to proper TypeInfo objects. + */ + public void resolveMemberTypes() { + TypeResolver resolver = TypeResolver.getInstance(); + + // Resolve method return types and parameter types + for (JSMethodInfo method : methods.values()) { + TypeInfo returnTypeInfo = resolver.resolveJSType(method.getReturnType()); + method.setReturnTypeInfo(returnTypeInfo); + + // Resolve parameter types + for (JSMethodInfo.JSParameterInfo param : method.getParameters()) { + TypeInfo paramTypeInfo = resolver.resolveJSType(param.getType()); + param.setTypeInfo(paramTypeInfo); + } + } + + // Resolve field types + for (JSFieldInfo field : fields.values()) { + TypeInfo fieldTypeInfo = resolver.resolveJSType(field.getType()); + field.setTypeInfo(fieldTypeInfo); + } + } + + public void resolveJSDocTypes() { + TypeResolver resolver = TypeResolver.getInstance(); + + if (jsDocInfo != null) { + resolveJSDocInfoTypes(jsDocInfo, resolver); + } + + for (JSMethodInfo method : methods.values()) { + JSDocInfo methodDoc = method.getJsDocInfo(); + if (methodDoc != null) { + resolveJSDocInfoTypes(methodDoc, resolver); + } + } + + for (JSFieldInfo field : fields.values()) { + JSDocInfo fieldDoc = field.getJsDocInfo(); + if (fieldDoc != null) { + resolveJSDocInfoTypes(fieldDoc, resolver); + } + } + } + + private void resolveJSDocInfoTypes(JSDocInfo jsDoc, TypeResolver resolver) { + JSDocTypeTag typeTag = jsDoc.getTypeTag(); + if (typeTag != null && typeTag.hasType() && typeTag.getTypeInfo() == null) { + TypeInfo resolved = resolver.resolveJSType(typeTag.getTypeName()); + typeTag.setType(typeTag.getTypeName(), resolved, typeTag.getTypeStart(), typeTag.getTypeEnd()); + } + + for (JSDocParamTag paramTag : jsDoc.getParamTags()) { + if (paramTag.hasType() && paramTag.getTypeInfo() == null) { + TypeInfo resolved = resolver.resolveJSType(paramTag.getTypeName()); + paramTag.setType(paramTag.getTypeName(), resolved, paramTag.getTypeStart(), paramTag.getTypeEnd()); + } + } + + JSDocReturnTag returnTag = jsDoc.getReturnTag(); + if (returnTag != null && returnTag.hasType() && returnTag.getTypeInfo() == null) { + TypeInfo resolved = resolver.resolveJSType(returnTag.getTypeName()); + returnTag.setType(returnTag.getTypeName(), resolved, returnTag.getTypeStart(), returnTag.getTypeEnd()); + } + } + + /** + * Resolves a type parameter to its bound TypeInfo. + * For example, if this type has "T extends EntityPlayerMP", resolveTypeParam("T") returns the TypeInfo for EntityPlayerMP. + * If no type parameter is found with that name, returns null. + */ + public TypeInfo resolveTypeParamToTypeInfo(String typeName) { + TypeParamInfo param = getTypeParam(typeName); + if (param != null) { + return param.getBoundTypeInfo(); + } + return null; + } + + /** + * Resolves a type parameter to its bound type name (for backward compatibility). + * @deprecated Use resolveTypeParamToTypeInfo instead + */ + @Deprecated + public String resolveTypeParam(String typeName) { + TypeParamInfo param = getTypeParam(typeName); + if (param != null && param.getBoundTypeInfo() != null) { + return param.getBoundTypeInfo().getSimpleName(); + } + return typeName; + } + + /** + * Get a method by name, including inherited methods. + */ + public JSMethodInfo getMethod(String name) { + JSMethodInfo method = methods.get(name); + if (method != null) return method; + + // Check parent + if (resolvedParent != null) { + return resolvedParent.getMethod(name); + } + return null; + } + + /** + * Get all methods with a given name (for overloads). + */ + public List getMethodOverloads(String name) { + List overloads = new ArrayList<>(); + + // Get from this type + if (methods.containsKey(name)) { + overloads.add(methods.get(name)); + } + // Get numbered overloads + int index = 1; + while (methods.containsKey(name + "$" + index)) { + overloads.add(methods.get(name + "$" + index)); + index++; + } + + // Get from parent + if (resolvedParent != null) { + overloads.addAll(resolvedParent.getMethodOverloads(name)); + } + + return overloads; + } + + /** + * Check if this type has a method (including inherited). + */ + public boolean hasMethod(String name) { + return getMethod(name) != null; + } + + /** + * Get a field by name, including inherited fields. + */ + public JSFieldInfo getField(String name) { + JSFieldInfo field = fields.get(name); + if (field != null) return field; + + // Check parent + if (resolvedParent != null) { + return resolvedParent.getField(name); + } + return null; + } + + /** + * Check if this type has a field (including inherited). + */ + public boolean hasField(String name) { + return getField(name) != null; + } + + /** + * Get an inner type by name. + */ + public JSTypeInfo getInnerType(String name) { + return innerTypes.get(name); + } + + @Override + public String toString() { + return "JSTypeInfo{" + fullName + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSTypeRegistry.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSTypeRegistry.java new file mode 100644 index 000000000..0d2f8e596 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/JSTypeRegistry.java @@ -0,0 +1,986 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import net.minecraft.client.Minecraft; +import net.minecraft.util.ResourceLocation; +import noppes.npcs.scripted.NpcAPI; +import noppes.npcs.client.gui.util.script.interpreter.bridge.DtsJavaBridge; + +import java.io.*; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Central registry for all TypeScript types parsed from .d.ts files. + * Also manages hook function signatures and type aliases. + */ +public class JSTypeRegistry { + + private static JSTypeRegistry INSTANCE; + + // All registered types by full name (e.g., "IPlayerEvent.InteractEvent") + private final Map types = new LinkedHashMap<>(); + + // Registered types by Java fully-qualified name (e.g., "noppes.npcs.api.entity.IPlayer") + private final Map typesByJavaFqn = new LinkedHashMap<>(); + + // Type aliases (simple name -> full type name) + private final Map typeAliases = new HashMap<>(); + + // Type full name -> origin string (modId:domain:relativePath) + private final Map typeOrigins = new HashMap<>(); + + // Alias name -> origin string (modId:domain:relativePath) + private final Map aliasOrigins = new HashMap<>(); + + // Context-aware hook function signatures: namespace -> functionName -> list of signatures + // The namespace is the event interface name (e.g., "INpcEvent", "IPlayerEvent") + // This allows any mod to register hooks without modifying an enum + private final Map>> contextHooks = new LinkedHashMap<>(); + + // Legacy hook storage: functionName -> list of (paramName, paramType) pairs + // Kept for backward compatibility with code that doesn't use contexts + private final Map> hooks = new LinkedHashMap<>(); + + // Fallback namespace for hooks that don't match a specific context + private static final String GLOBAL_NAMESPACE = "Global"; + + // Global object instances: name -> type (e.g., "API" -> "AbstractNpcAPI") + // These are treated as instance objects, not static classes + private final Map globalEngineObjects = new LinkedHashMap<>(); + + // Primitive types + private static final Set PRIMITIVES = new HashSet<>(Arrays.asList( + "number", "string", "boolean", "void", "any", "null", "undefined", "never", "object" + )); + + private boolean initialized = false; + private boolean initializationAttempted = false; + + private String currentSource = null; + + public static JSTypeRegistry getInstance() { + if (INSTANCE == null) { + INSTANCE = new JSTypeRegistry(); + } + return INSTANCE; + } + + private JSTypeRegistry() {} + + /** + * Initialize the registry from the embedded resources. + * Recursively loads all .d.ts files from assets/customnpcs/api/ + */ + public void initializeFromResources() { + if (initialized || initializationAttempted) return; + initializationAttempted = true; + + try { + TypeScriptDefinitionParser parser = new TypeScriptDefinitionParser(this); + + List dtsFiles = DtsModScanner.collectDtsFilesFromMods(); + if (dtsFiles.isEmpty()) { + Set fallbackFiles = findAllDtsFilesInResources("assets/customnpcs/api"); + + System.out.println("[JSTypeRegistry] Found " + fallbackFiles.size() + " .d.ts files in resources"); + + if (fallbackFiles.contains("hooks.d.ts")) { + loadResourceFile(parser, "hooks.d.ts"); + } + if (fallbackFiles.contains("index.d.ts")) { + loadResourceFile(parser, "index.d.ts"); + } + + for (String filePath : fallbackFiles) { + if (!filePath.equals("hooks.d.ts") && !filePath.equals("index.d.ts")) { + loadResourceFile(parser, filePath); + } + } + } else { + DtsModScanner.logSummary(dtsFiles); + loadDtsFiles(parser, dtsFiles); + } + + // Phase 2: Resolve all type parameters now that all types are loaded + resolveAllTypeParameters(); + + // Phase 2b: Resolve member types (return types, field types, param types) + resolveAllMemberTypes(); + + // Phase 2c: Resolve JSDoc types (@param, @return, @type type references) + resolveAllJSDocTypes(); + + resolveInheritance(); + registerEngineGlobalObjects(); + + initialized = true; + System.out.println("[JSTypeRegistry] Loaded " + types.size() + " types, " + hooks.size() + " hooks from resources"); + } catch (Exception e) { + System.err.println("[JSTypeRegistry] Failed to load type definitions from resources: " + e.getMessage()); + e.printStackTrace(); + } + } + + private void loadDtsFiles(TypeScriptDefinitionParser parser, List dtsFiles) { + DtsModScanner.sortDtsFiles(dtsFiles); + for (DtsModScanner.DtsFileRef ref : dtsFiles) { + try (InputStream is = ref.openStream()) { + if (is == null) { + continue; + } + String content = readFully(new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))); + setCurrentSource(ref.getOrigin()); + parser.parseDefinitionFile(content, ref.getRelativePath()); + } catch (Exception e) { + System.err.println("[JSTypeRegistry] Failed to load " + ref.getOrigin() + ": " + e.getMessage()); + } finally { + setCurrentSource(null); + } + } + } + + private String readFully(BufferedReader reader) throws IOException { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } + + /** + * Recursively find all .d.ts files in the resources directory. + * Similar to ClassIndex.addPackage, scans both file system and JAR resources. + * + * @param resourcePath The full resource path (e.g., "assets/customnpcs/api") + */ + private Set findAllDtsFilesInResources(String resourcePath) { + Set dtsFiles = new HashSet<>(); + + try { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + Enumeration resources = classLoader.getResources(resourcePath); + + while (resources.hasMoreElements()) { + URL resource = resources.nextElement(); + + if (resource.getProtocol().equals("file")) { + // Scan file system directory + File directory = new File(resource.getFile()); + scanDirectoryForDts(directory, "", dtsFiles); + } else if (resource.getProtocol().equals("jar")) { + // Scan JAR file + String jarPath = resource.getPath(); + if (jarPath.startsWith("file:")) { + jarPath = jarPath.substring(5); + } + int separatorIndex = jarPath.indexOf("!"); + if (separatorIndex != -1) { + jarPath = jarPath.substring(0, separatorIndex); + } + scanJarForDts(jarPath, resourcePath, dtsFiles); + } + } + } catch (Exception e) { + System.err.println("[JSTypeRegistry] Error scanning for .d.ts files: " + e.getMessage()); + } + + return dtsFiles; + } + + /** + * Recursively scan a file system directory for .d.ts files. + * + * @param directory The directory to scan + * @param currentPath The relative path from the base (used for building file paths) + * @param dtsFiles The set to collect .d.ts file paths + */ + private void scanDirectoryForDts(File directory, String currentPath, Set dtsFiles) { + if (!directory.exists() || !directory.isDirectory()) { + return; + } + + File[] files = directory.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + String fileName = file.getName(); + String filePath = currentPath.isEmpty() ? fileName : currentPath + "/" + fileName; + + if (file.isDirectory()) { + // Recursively scan subdirectory + scanDirectoryForDts(file, filePath, dtsFiles); + } else if (fileName.endsWith(".d.ts")) { + // Add .d.ts file path + dtsFiles.add(filePath); + } + } + } + + /** + * Scan a JAR file for .d.ts files in the specified resource path. + */ + private void scanJarForDts(String jarPath, String resourcePath, Set dtsFiles) { + try { + JarFile jarFile = new JarFile(jarPath); + Enumeration entries = jarFile.entries(); + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String entryName = entry.getName(); + + // Check if entry is under our resource path and is a .d.ts file + if (entryName.startsWith(resourcePath + "/") && entryName.endsWith(".d.ts")) { + // Convert to relative path from resourcePath + String relativePath = entryName.substring(resourcePath.length() + 1); + dtsFiles.add(relativePath); + } + } + + jarFile.close(); + } catch (Exception e) { + System.err.println("[JSTypeRegistry] Error scanning JAR for .d.ts files: " + e.getMessage()); + } + } + + /** + * Load a specific .d.ts file from resources. + */ + private void loadResourceFile(TypeScriptDefinitionParser parser, String fileName) { + try { + ResourceLocation loc = new ResourceLocation("customnpcs", "api/" + fileName); + InputStream is = Minecraft.getMinecraft().getResourceManager().getResource(loc).getInputStream(); + if (is != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + parser.parseDefinitionFile(sb.toString(), fileName); + } + } + } catch (Exception e) { + // File might not exist, that's ok + System.out.println("[JSTypeRegistry] Could not load " + fileName + ": " + e.getMessage()); + } + } + + /** + * Initialize the registry from a directory containing .d.ts files. + */ + public void initializeFromDirectory(File directory) { + if (initialized) return; + + try { + TypeScriptDefinitionParser parser = new TypeScriptDefinitionParser(this); + parser.parseDirectory(directory); + resolveAllTypeParameters(); + resolveAllMemberTypes(); + resolveAllJSDocTypes(); + resolveInheritance(); + initialized = true; + System.out.println("[JSTypeRegistry] Loaded " + types.size() + " types, " + hooks.size() + " hooks"); + } catch (IOException e) { + System.err.println("[JSTypeRegistry] Failed to load type definitions: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Initialize the registry from a VSIX file. + */ + public void initializeFromVsix(File vsixFile) { + if (initialized) return; + + try { + TypeScriptDefinitionParser parser = new TypeScriptDefinitionParser(this); + parser.parseVsixArchive(vsixFile); + resolveAllTypeParameters(); + resolveAllMemberTypes(); + resolveAllJSDocTypes(); + resolveInheritance(); + initialized = true; + System.out.println("[JSTypeRegistry] Loaded " + types.size() + " types, " + hooks.size() + " hooks from VSIX"); + } catch (IOException e) { + System.err.println("[JSTypeRegistry] Failed to load VSIX: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Register a type. + * If a type with matching javaFqn or fullName already exists, merges instead of discarding. + */ + public void registerType(JSTypeInfo type) { + String fullName = type.getFullName(); + String javaFqn = type.getJavaFqn(); + + JSTypeInfo existingType = null; + + if (javaFqn != null && !javaFqn.isEmpty()) { + existingType = typesByJavaFqn.get(javaFqn); + } + + if (existingType == null && types.containsKey(fullName)) { + existingType = types.get(fullName); + } + + if (existingType != null) { + mergeType(existingType, type, getCurrentSource()); + + // Preserve an addressable key for the incoming declared name. + // Some .d.ts parsing paths can encounter the same Java type via multiple textual names + // (e.g., due to nested namespaces). Even if we merge by @javaFqn, callers still expect + // lookups like "INpcEvent.DamagedEvent" to work. + if (!types.containsKey(fullName)) { + types.put(fullName, existingType); + typeOrigins.put(fullName, getCurrentSource()); + } + return; + } + + types.put(fullName, type); + typeOrigins.put(fullName, getCurrentSource()); + if (javaFqn != null && !javaFqn.isEmpty()) { + if (!typesByJavaFqn.containsKey(javaFqn)) { + typesByJavaFqn.put(javaFqn, type); + } else { + System.out.println("[JSTypeRegistry] Duplicate javaFqn " + javaFqn + " from " + getCurrentSource()); + } + } + } + + /** + * Get a type by Java fully-qualified name. + */ + public JSTypeInfo getTypeByJavaFqn(String javaFqn) { + if (javaFqn == null || javaFqn.isEmpty()) return null; + return typesByJavaFqn.get(javaFqn); + } + + /** + * Register a type alias. + */ + public void registerTypeAlias(String alias, String fullType) { + if (typeAliases.containsKey(alias)) { + String existing = typeAliases.get(alias); + if (!Objects.equals(existing, fullType)) { + logAliasCollision(alias, existing, fullType, getCurrentSource()); + } + return; + } + typeAliases.put(alias, fullType); + aliasOrigins.put(alias, getCurrentSource()); + } + + /** + * Register a hook function signature with a namespace. + * + * @param namespace The event interface namespace (e.g., "INpcEvent", "IPlayerEvent") + * @param functionName The hook function name (e.g., "interact", "damaged") + * @param paramName The parameter name (e.g., "event") + * @param paramType The parameter type (e.g., "INpcEvent.InteractEvent") + */ + public void registerHook(String namespace, String functionName, String paramName, String paramType) { + HookSignature sig = new HookSignature(paramName, paramType, null, namespace); + + // Add to context-specific map + contextHooks.computeIfAbsent(namespace, k -> new LinkedHashMap<>()) + .computeIfAbsent(functionName, k -> new ArrayList<>()) + .add(sig); + + // Also add to legacy hooks map for backward compatibility + hooks.computeIfAbsent(functionName, k -> new ArrayList<>()).add(sig); + } + + /** + * Get a type by name (handles aliases and primitives). + */ + public JSTypeInfo getType(String name) { + return getType(name, new HashSet<>()); + } + + /** + * Internal method with cycle detection for type aliases. + */ + private JSTypeInfo getType(String name, Set visited) { + if (name == null || name.isEmpty()) return null; + + // Strip array brackets for lookup + String baseName = name.replace("[]", "").trim(); + + // Check if primitive + if (PRIMITIVES.contains(baseName)) { + return null; // Primitives don't have JSTypeInfo + } + + // Detect circular alias references + if (visited.contains(baseName)) { + // Circular alias detected - try direct type lookup as fallback + if (types.containsKey(baseName)) { + return types.get(baseName); + } + return null; + } + visited.add(baseName); + + // Direct lookup in types first (higher priority than aliases) + if (types.containsKey(baseName)) { + return types.get(baseName); + } + + // Check type aliases + if (typeAliases.containsKey(baseName)) { + String resolved = typeAliases.get(baseName); + // Don't follow alias if it resolves to itself + if (resolved.equals(baseName)) { + return null; + } + return getType(resolved, visited); + } + + // Try simple name lookup (for types like "IEntity" without namespace) + for (JSTypeInfo type : types.values()) { + if (type.getSimpleName().equals(baseName)) { + return type; + } + } + return null; + } + + /** + * Check if a type name is a known primitive. + */ + public boolean isPrimitive(String typeName) { + return PRIMITIVES.contains(typeName); + } + + /** + * Get hook signatures for a function name. + */ + public List getHookSignatures(String functionName) { + return hooks.getOrDefault(functionName, Collections.emptyList()); + } + + /** + * Check if a function name is a known hook. + */ + public boolean isHook(String functionName) { + return hooks.containsKey(functionName); + } + + /** + * Get the parameter type for a hook function. + * If multiple overloads exist, returns the first one. + */ + public String getHookParameterType(String functionName) { + List sigs = hooks.get(functionName); + if (sigs != null && !sigs.isEmpty()) { + return sigs.get(0).paramType; + } + return null; + } + + // ==================== Context-Aware Hook Methods ==================== + + /** + * Get hook signatures for a specific script context. + * Falls back to GLOBAL context if not found in the specified context. + * + * @param namespace The event interface namespace (e.g., "INpcEvent", "IPlayerEvent") + * @param functionName The hook function name + * @return List of hook signatures, or empty list if not found + */ + public List getHookSignatures(String namespace, String functionName) { + // First try the specific namespace + Map> namespaceMap = contextHooks.get(namespace); + if (namespaceMap != null) { + List sigs = namespaceMap.get(functionName); + if (sigs != null && !sigs.isEmpty()) { + return sigs; + } + } + + // Fall back to GLOBAL namespace + if (!GLOBAL_NAMESPACE.equals(namespace)) { + Map> globalMap = contextHooks.get(GLOBAL_NAMESPACE); + if (globalMap != null) { + List sigs = globalMap.get(functionName); + if (sigs != null && !sigs.isEmpty()) { + return sigs; + } + } + } + + return Collections.emptyList(); + } + + /** + * Check if a function name is a known hook in a specific namespace. + * + * @param namespace The event interface namespace + * @param functionName The hook function name + * @return true if the hook exists in this namespace or GLOBAL + */ + public boolean isHook(String namespace, String functionName) { + return !getHookSignatures(namespace, functionName).isEmpty(); + } + + /** + * Get the parameter type for a hook function in a specific namespace. + * Falls back to GLOBAL namespace if not found. + * + * @param namespace The event interface namespace + * @param functionName The hook function name + * @return The parameter type, or null if not found + */ + public String getHookParameterType(String namespace, String functionName) { + List sigs = getHookSignatures(namespace, functionName); + if (!sigs.isEmpty()) { + return sigs.get(0).paramType; + } + return null; + } + + /** + * Get all hook names for a specific namespace. + * + * @param namespace The event interface namespace + * @return Set of hook function names available in this namespace + */ + public Set getHookNames(String namespace) { + Set names = new HashSet<>(); + + // Add hooks from the specific namespace + Map> namespaceMap = contextHooks.get(namespace); + if (namespaceMap != null) { + names.addAll(namespaceMap.keySet()); + } + + // Also add GLOBAL hooks + if (!GLOBAL_NAMESPACE.equals(namespace)) { + Map> globalMap = contextHooks.get(GLOBAL_NAMESPACE); + if (globalMap != null) { + names.addAll(globalMap.keySet()); + } + } + + return names; + } + + /** + * Get all hooks organized by namespace. + * + * @return Map of namespace -> hookName -> signatures + */ + public Map>> getAllContextHooks() { + return Collections.unmodifiableMap(contextHooks); + } + + // ==================== MULTI-NAMESPACE LOOKUP (for ScriptContext) ==================== + + /** + * Get hook signatures by searching through multiple namespaces. + * This is used when a ScriptContext has multiple event types (e.g., Player has + * IPlayerEvent, IAnimationEvent, IPartyEvent, etc.) + * + * @param namespaces List of namespaces to search (in priority order) + * @param functionName The hook function name + * @return List of hook signatures from the first matching namespace, or empty list + */ + public List getHookSignatures(List namespaces, String functionName) { + // Search through all namespaces in order + for (String namespace : namespaces) { + Map> namespaceMap = contextHooks.get(namespace); + if (namespaceMap != null) { + List sigs = namespaceMap.get(functionName); + if (sigs != null && !sigs.isEmpty()) { + return sigs; + } + } + } + + // Fall back to GLOBAL namespace + Map> globalMap = contextHooks.get(GLOBAL_NAMESPACE); + if (globalMap != null) { + List sigs = globalMap.get(functionName); + if (sigs != null && !sigs.isEmpty()) { + return sigs; + } + } + + return Collections.emptyList(); + } + + /** + * Check if a function name is a known hook in any of the given namespaces. + * + * @param namespaces List of namespaces to search + * @param functionName The hook function name + * @return true if the hook exists in any namespace or GLOBAL + */ + public boolean isHook(List namespaces, String functionName) { + return !getHookSignatures(namespaces, functionName).isEmpty(); + } + + /** + * Get the parameter type for a hook function, searching through multiple namespaces. + * + * @param namespaces List of namespaces to search + * @param functionName The hook function name + * @return The parameter type, or null if not found + */ + public String getHookParameterType(List namespaces, String functionName) { + List sigs = getHookSignatures(namespaces, functionName); + if (!sigs.isEmpty()) { + return sigs.get(0).paramType; + } + return null; + } + + /** + * Get all hook names available in any of the given namespaces. + * + * @param namespaces List of namespaces to search + * @return Set of hook function names available in these namespaces + */ + public Set getHookNames(List namespaces) { + Set names = new HashSet<>(); + + // Add hooks from all specified namespaces + for (String namespace : namespaces) { + Map> namespaceMap = contextHooks.get(namespace); + if (namespaceMap != null) { + names.addAll(namespaceMap.keySet()); + } + } + + // Also add GLOBAL hooks + Map> globalMap = contextHooks.get(GLOBAL_NAMESPACE); + if (globalMap != null) { + names.addAll(globalMap.keySet()); + } + + return names; + } + + /** + * Resolve all type parameters for all types. + * Called after all .d.ts files are loaded (Phase 2). + * This ensures that type parameters can reference any type in the registry. + */ + public void resolveAllTypeParameters() { + for (JSTypeInfo type : types.values()) { + type.resolveTypeParameters(); + } + } + + /** + * Resolve all member types (return types, field types, parameter types) for all types. + * Called after resolveAllTypeParameters (Phase 2b). + * This resolves types like "Java.java.io.File" to proper TypeInfo objects. + */ + public void resolveAllMemberTypes() { + for (JSTypeInfo type : types.values()) + type.resolveMemberTypes(); + } + + public void resolveAllJSDocTypes() { + for (JSTypeInfo type : types.values()) + type.resolveJSDocTypes(); + } + + /** + * Resolve inheritance relationships between types. + * For each type, walks up the parent chain and resolves all ancestors. + * Efficient O(n) approach - each type's chain is walked once. + */ + public void resolveInheritance() { + for (JSTypeInfo type : types.values()) { + JSTypeInfo child = type; + while (child != null && child.getExtendsType() != null && child.getResolvedParent() == null) { + JSTypeInfo parent = getType(child.getExtendsType()); + if (parent != null) { + child.setResolvedParent(parent); + } + child = parent; + } + } + } + + /** + * Get all registered types. + */ + public Collection getAllTypes() { + return types.values(); + } + + /** + * Get all type names. + */ + public Set getTypeNames() { + return types.keySet(); + } + + /** + * Get all hook function names. + */ + public Set getHookNames() { + return hooks.keySet(); + } + + /** + * Get all registered hooks. + */ + public Map> getAllHooks() { + return hooks; + } + + /** + * Register a global object instance (like API, DBCAPI from NpcAPI.engineObjects). + * These are treated as instance objects, not static classes. + * @param name The global variable name (e.g., "API") + * @param typeName The type name (e.g., "AbstractNpcAPI") + */ + public void registerGlobalObject(String name, String typeName) { + globalEngineObjects.put(name, typeName); + } + + /** + * Get the type name for a global object. + * @param name The global variable name + * @return The type name, or null if not a registered global object + */ + public String getGlobalObjectType(String name) { + return globalEngineObjects.get(name); + } + + /** + * Check if a name is a registered global object instance. + */ + public boolean isGlobalObject(String name) { + return globalEngineObjects.containsKey(name); + } + + /** + * Get all registered global objects. + */ + public Map getGlobalEngineObjects() { + return Collections.unmodifiableMap(globalEngineObjects); + } + + /** + * Registers global objects from NpcAPI.engineObjects into JSTypeRegistry. + * This allows the IDE to recognize API, DBCAPI, etc. as instance objects with autocomplete. + */ + private void registerEngineGlobalObjects() { + Map engineObjects = new HashMap<>(NpcAPI.engineObjects); + engineObjects.put("API", NpcAPI.Instance()); //default API object + + if (engineObjects != null) { + for (Map.Entry entry : engineObjects.entrySet()) { + String name = entry.getKey(); // e.g., "API", "DBCAPI" + Object obj = entry.getValue(); // e.g., AbstractNpcAPI instance + + if (obj != null) { + // Get the actual class name + String concreteClassName = obj.getClass().getSimpleName(); + + // Map concrete implementation classes to their abstract interface names + // (because .d.ts files define the abstract interfaces, not the implementations) + String typeName = mapConcreteToAbstractClassName(concreteClassName); + + // Register as global object (instance, not static) + registerGlobalObject(name, typeName); + } + } + } + } + + /** + * Maps concrete implementation class names to their abstract interface names. + * For example: "NpcAPI" -> "AbstractNpcAPI", "DBCAPI" -> "AbstractDBCAPI" + */ + private String mapConcreteToAbstractClassName(String concreteClassName) { + // Check if the type exists in the registry as-is + if (types.containsKey(concreteClassName)) + return concreteClassName; + + // Try prepending "Abstract" if it doesn't exist + String abstractName = "Abstract" + concreteClassName; + if (types.containsKey(abstractName)) + return abstractName; + + // Default: return the original name + return concreteClassName; + } + + /** + * Check if initialized. + */ + public boolean isInitialized() { + return initialized; + } + + /** + * Clear the registry (for reloading). + */ + public void clear() { + types.clear(); + typesByJavaFqn.clear(); + typeAliases.clear(); + typeOrigins.clear(); + aliasOrigins.clear(); + hooks.clear(); + contextHooks.clear(); + globalEngineObjects.clear(); + DtsJavaBridge.clearCache(); + initialized = false; + } + + private void setCurrentSource(String source) { + currentSource = source; + } + + private String getCurrentSource() { + return currentSource == null ? "unknown" : currentSource; + } + + private void logTypeCollision(String fullName, String incomingSource) { + String existingSource = typeOrigins.getOrDefault(fullName, "unknown"); + System.out.println("[JSTypeRegistry] Duplicate type " + fullName + " from " + incomingSource + " (kept " + existingSource + ")"); + } + + private void logAliasCollision(String alias, String existingType, String incomingType, String incomingSource) { + String existingSource = aliasOrigins.getOrDefault(alias, "unknown"); + System.out.println("[JSTypeRegistry] Duplicate alias " + alias + " from " + incomingSource + " (kept " + existingSource + ")"); + System.out.println("[JSTypeRegistry] Alias " + alias + " existing=" + existingType + " incoming=" + incomingType); + } + + /** + * Find the method key in a type's methods map that matches the given method name and parameter count. + * This handles overload keys (methodName, methodName$1, methodName$2, etc.) + * + * @param type The type to search in + * @param methodName The method name to search for + * @param paramCount The parameter count to match + * @return The method key if found, or null if no match + */ + private String findOwnMethodKeyByParamCount(JSTypeInfo type, String methodName, int paramCount) { + Map methods = type.getMethods(); + + // Check direct key first + JSMethodInfo direct = methods.get(methodName); + if (direct != null && direct.getParameterCount() == paramCount) { + return methodName; + } + + // Check overload keys: methodName$1, methodName$2, etc. + int index = 1; + String overloadKey = methodName + "$" + index; + while (methods.containsKey(overloadKey)) { + JSMethodInfo overload = methods.get(overloadKey); + if (overload.getParameterCount() == paramCount) { + return overloadKey; + } + index++; + overloadKey = methodName + "$" + index; + } + + return null; // No match found + } + + /** + * Merge an incoming type into an existing type. + * This allows addon mods to patch base mod types via .d.ts files. + * + * @param existing The existing type to merge into + * @param incoming The incoming type to merge from + * @param incomingSource The source of the incoming type (for logging) + */ + private void mergeType(JSTypeInfo existing, JSTypeInfo incoming, String incomingSource) { + int methodsMerged = 0; + int fieldsMerged = 0; + + // Merge methods (replace matching overloads by param count) + for (Map.Entry entry : incoming.getMethods().entrySet()) { + JSMethodInfo incomingMethod = entry.getValue(); + String methodName = incomingMethod.getName(); + int paramCount = incomingMethod.getParameterCount(); + + // Find existing method with same param count + String existingKey = findOwnMethodKeyByParamCount(existing, methodName, paramCount); + if (existingKey != null) { + // Replace existing overload + existing.getMethods().put(existingKey, incomingMethod); + incomingMethod.setContainingType(existing); + methodsMerged++; + } else { + // Add as new overload + existing.addMethod(incomingMethod); + methodsMerged++; + } + } + + // Merge fields (replace by name) + for (Map.Entry entry : incoming.getFields().entrySet()) { + String fieldName = entry.getKey(); + JSFieldInfo incomingField = entry.getValue(); + existing.getFields().put(fieldName, incomingField); + incomingField.setContainingType(existing); + fieldsMerged++; + } + + // Merge metadata (fill gaps only - preserve existing non-null values) + if (existing.getJavaFqn() == null && incoming.getJavaFqn() != null) { + existing.setJavaFqn(incoming.getJavaFqn()); + } + if (existing.getJsDocInfo() == null && incoming.getJsDocInfo() != null) { + existing.setJsDocInfo(incoming.getJsDocInfo()); + } + if (existing.getExtendsType() == null && incoming.getExtendsType() != null) { + existing.setExtends(incoming.getExtendsType()); + } + + // Log merge details + System.out.println("[JSTypeRegistry] Merged type " + existing.getFullName() + + " with " + methodsMerged + " methods, " + fieldsMerged + + " fields from " + incomingSource); + } + + /** + * Represents a hook function signature with its namespace. + */ + public static class HookSignature { + public final String paramName; + public final String paramType; + public final String doc; + public final String namespace; // The event interface namespace (e.g., "INpcEvent") + + public HookSignature(String paramName, String paramType) { + this(paramName, paramType, null, GLOBAL_NAMESPACE); + } + + public HookSignature(String paramName, String paramType, String doc) { + this(paramName, paramType, doc, GLOBAL_NAMESPACE); + } + + public HookSignature(String paramName, String paramType, String doc, String namespace) { + this.paramName = paramName; + this.paramType = paramType; + this.doc = doc; + this.namespace = namespace != null ? namespace : GLOBAL_NAMESPACE; + } + + @Override + public String toString() { + return paramName + ": " + paramType + " [" + namespace + "]"; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/TypeParamInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/TypeParamInfo.java new file mode 100644 index 000000000..846a23f47 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/TypeParamInfo.java @@ -0,0 +1,68 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +/** + * Represents a generic type parameter like "T extends Entity". + * Stores the parameter name and both the string representation (for deferred resolution) + * and the resolved TypeInfo for the bound. + */ +public class TypeParamInfo { + private final String name; // e.g., "T" + private final String boundType; // Simple name like "EntityPlayerMP" (may be null) + private final String fullBoundType; // Full name like "net.minecraft.entity.player.EntityPlayerMP" (may be null) + private TypeInfo boundTypeInfo; // Resolved TypeInfo for the bound (resolved in Phase 2) + + /** + * Constructor for parse-time (Phase 1) - stores string names only. + */ + public TypeParamInfo(String name, String boundType, String fullBoundType) { + this.name = name; + this.boundType = boundType; + this.fullBoundType = fullBoundType; + this.boundTypeInfo = null; // Will be resolved later + } + + public String getName() { return name; } + + public TypeInfo getBoundTypeInfo() { + return boundTypeInfo; + } + + /** + * Resolve the bound type using TypeResolver. + * Called during Phase 2 after all types are loaded. + */ + public void resolveBoundType() { + if (boundTypeInfo != null) + return; // Already resolved + + if (fullBoundType != null && !fullBoundType.isEmpty()) { + // Try to load the Java class using the full name + boundTypeInfo = TypeResolver.getInstance().resolveFullName(fullBoundType); + } else if (boundType != null && !boundType.isEmpty()) { + // Try to resolve using the simple name + boundTypeInfo = TypeResolver.getInstance().resolveJSType(boundType); + } + } + + /** + * Get display name for the bound type, including generic arguments. + */ + public String getBoundTypeName() { + if (boundTypeInfo == null) + return null; + return boundTypeInfo.getDisplayName(); + } + + @Override + public String toString() { + if (boundTypeInfo != null) { + return name + " extends " + boundTypeInfo.getDisplayName(); + } else if (boundType != null || fullBoundType != null) { + return name + " extends " + (boundType != null ? boundType : fullBoundType); + } + return name; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/TypeScriptDefinitionParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/TypeScriptDefinitionParser.java new file mode 100644 index 000000000..a0798da25 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/js_parser/TypeScriptDefinitionParser.java @@ -0,0 +1,707 @@ +package noppes.npcs.client.gui.util.script.interpreter.js_parser; + +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocTag; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeStringNormalizer; + +import java.io.*; +import java.util.*; +import java.util.regex.*; +import java.util.zip.*; + +/** + * Parses TypeScript definition files (.d.ts) to extract type information. + * Can read from individual files, directories, or .vsix archives. + */ +public class TypeScriptDefinitionParser { + + // Patterns for parsing .d.ts content + // Updated to capture generic type parameters like: export interface IEntity + private static final Pattern INTERFACE_PATTERN = Pattern.compile( + "export\\s+interface\\s+(\\w+)(?:<([^>]*)>)?(?:\\s+extends\\s+([^{]+?))?\\s*\\{"); + + // Pattern for nested interfaces without export keyword + private static final Pattern NESTED_INTERFACE_PATTERN = Pattern.compile( + "(?]*)>)?(?:\\s+extends\\s+([^{]+?))?\\s*\\{"); + + // Similar pattern for classes + private static final Pattern CLASS_PATTERN = Pattern.compile( + "export\\s+class\\s+(\\w+)(?:<([^>]*)>)?(?:\\s+extends\\s+([^{]+?))?\\s*\\{"); + + // Pattern to parse individual type parameters like: T extends EntityPlayerMP /* net.minecraft.entity.player.EntityPlayerMP */ + private static final Pattern TYPE_PARAM_PATTERN = Pattern.compile( + "(\\w+)(?:\\s+extends\\s+(\\w+)(?:\\s*/\\*\\s*([\\w.]+)\\s*\\*/)?)?"); + + // Match both "export namespace" and plain "namespace" (for declare global blocks) + private static final Pattern NAMESPACE_PATTERN = Pattern.compile( + "(?:export\\s+)?namespace\\s+(\\w+)\\s*\\{"); + + // Make semicolon optional for type aliases (TypeScript doesn't require them) + private static final Pattern TYPE_ALIAS_PATTERN = Pattern.compile( + "export\\s+type\\s+(\\w+)\\s*=\\s*([^;\\n]+);?"); + + private static final Pattern METHOD_PATTERN = Pattern.compile( + "(?m)^\\s*(\\w+)\\s*\\((.*)\\)\\s*:\\s*([^;]+);", Pattern.MULTILINE); + + private static final Pattern FIELD_PATTERN = Pattern.compile( + "^\\s*(readonly\\s+)?(\\w+)\\s*:\\s*([^;]+);", Pattern.MULTILINE); + + private static final Pattern GLOBAL_FUNCTION_PATTERN = Pattern.compile( + "function\\s+(\\w+)\\s*\\((.*)\\)\\s*:\\s*([^;]+);"); + + private static final Pattern GLOBAL_TYPE_ALIAS_PATTERN = Pattern.compile( + "type\\s+(\\w+)\\s*=\\s*import\\(['\"]([^'\"]+)['\"]\\)\\.([\\w.]+);"); + + // Pattern for context-namespaced hooks like: declare namespace INpcEvent { ... } + // Matches any "declare namespace {" where Name starts with I and contains Event, + // or any other namespace pattern used for hooks + private static final Pattern HOOKS_NAMESPACE_PATTERN = Pattern.compile( + "declare\\s+namespace\\s+(I\\w*Event|\\w+)\\s*\\{", Pattern.MULTILINE); + + private final JSTypeRegistry registry; + + public TypeScriptDefinitionParser(JSTypeRegistry registry) { + this.registry = registry; + } + + /** + * Parse all .d.ts files from a VSIX archive. + */ + public void parseVsixArchive(File vsixFile) throws IOException { + try (ZipFile zip = new ZipFile(vsixFile)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.getName().endsWith(".d.ts")) { + try (InputStream is = zip.getInputStream(entry); + BufferedReader reader = new BufferedReader(new InputStreamReader(is))) { + String content = readFully(reader); + String fileName = entry.getName(); + parseDefinitionFile(content, fileName); + } + } + } + } + } + + /** + * Parse all .d.ts files from a directory recursively. + */ + public void parseDirectory(File directory) throws IOException { + if (!directory.isDirectory()) { + throw new IllegalArgumentException("Not a directory: " + directory); + } + parseDirectoryRecursive(directory); + } + + private void parseDirectoryRecursive(File dir) throws IOException { + File[] files = dir.listFiles(); + if (files == null) return; + + for (File file : files) { + if (file.isDirectory()) { + parseDirectoryRecursive(file); + } else if (file.getName().endsWith(".d.ts")) { + parseFile(file); + } + } + } + + /** + * Parse a single .d.ts file. + */ + public void parseFile(File file) throws IOException { + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String content = readFully(reader); + parseDefinitionFile(content, file.getName()); + } + } + + /** + * Parse a .d.ts file content. + */ + public void parseDefinitionFile(String content, String fileName) { + // Special handling for hooks.d.ts - extract function signatures + if (fileName.contains("hooks.d.ts")) { + parseHooksFile(content); + return; + } + + // Special handling for index.d.ts - extract global type aliases + if (fileName.contains("index.d.ts")) { + parseIndexFile(content); + return; + } + + // Parse regular interface files + String packageName = derivePackageName(fileName); + parseInterfaceFile(content, null, packageName); + } + + /** + * Parse hooks.d.ts to extract function signatures for JS hooks. + * + * Hooks are organized by their parent event interface, with the interface name + * used directly as the namespace. Any mod can register its own event interfaces + * and they will be automatically parsed: + * + * declare namespace INpcEvent { + * function interact(event: INpcEvent.InteractEvent): void; + * function init(event: INpcEvent.InitEvent): void; + * } + * + * declare namespace IPlayerEvent { + * function interact(event: IPlayerEvent.InteractEvent): void; + * } + * + * declare namespace IDBCEvent { + * function customHook(event: IDBCEvent.CustomEvent): void; + * } + * + * The namespace name is stored as a string, allowing dynamic registration + * without requiring enum modifications. + */ + private void parseHooksFile(String content) { + // Parse namespaced hooks (e.g., declare namespace INpcEvent { ... }) + // The namespace name is used directly - any event interface can register hooks + Matcher namespaceMatcher = HOOKS_NAMESPACE_PATTERN.matcher(content); + while (namespaceMatcher.find()) { + String namespace = namespaceMatcher.group(1); // e.g., "INpcEvent", "IPlayerEvent", "IDBCEvent" + + // Find the body of this namespace + int bodyStart = namespaceMatcher.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd > bodyStart) { + String namespaceBody = content.substring(bodyStart, bodyEnd); + + // Parse functions within this namespace + Matcher funcMatcher = GLOBAL_FUNCTION_PATTERN.matcher(namespaceBody); + while (funcMatcher.find()) { + String funcName = funcMatcher.group(1); + String params = funcMatcher.group(2); + + // Parse parameter - format is "paramName: TypeName" + if (!params.isEmpty()) { + String[] parts = params.split(":\\s*", 2); + if (parts.length == 2) { + String paramName = parts[0].trim(); + String paramType = parts[1].trim(); + // Register hook with the namespace string directly + registry.registerHook(namespace, funcName, paramName, paramType); + } + } + } + } + } + } + + /** + * Parse index.d.ts to extract global type aliases. + */ + private void parseIndexFile(String content) { + Matcher m = GLOBAL_TYPE_ALIAS_PATTERN.matcher(content); + while (m.find()) { + String aliasName = m.group(1); + String importPath = m.group(2); + String typeName = m.group(3); + registry.registerTypeAlias(aliasName, typeName); + } + } + + /** + * Parse interface and class definitions from content. + */ + private void parseInterfaceFile(String content, String parentNamespace, String packageName) { + // IMPORTANT: + // Many generated .d.ts files declare nested exported interfaces inside `namespace X { ... }` blocks. + // If we scan `content` directly for `export interface` / `export class`, we'll accidentally treat + // those nested exports as top-level for the current `parentNamespace`. + // That pollutes the registry with incorrectly-scoped type names (e.g. "DamagedEvent" instead of + // "INpcEvent.DamagedEvent"), and later registry merges (by @javaFqn) can prevent the correctly + // namespaced key from ever being registered. + // + // To avoid this, we strip namespace blocks out of the scan input and then parse namespaces + // explicitly via the NAMESPACE_PATTERN recursion further below. + String scanContent = stripNamespaceBlocks(content); + + // Find exported interfaces + Matcher interfaceMatcher = INTERFACE_PATTERN.matcher(scanContent); + while (interfaceMatcher.find()) { + String interfaceName = interfaceMatcher.group(1); + String typeParamsStr = interfaceMatcher.group(2); // e.g., "T extends EntityPlayerMP /* net.minecraft.entity.player.EntityPlayerMP */" + String extendsClause = interfaceMatcher.group(3); + + JSTypeInfo typeInfo = new JSTypeInfo(interfaceName, parentNamespace); + + JSDocInfo jsDoc = extractJSDocBefore(content, interfaceMatcher.start()); + if (jsDoc != null) { + typeInfo.setJsDocInfo(jsDoc); + } + + String javaFqn = findJavaFqn(jsDoc, packageName, typeInfo.getFullName()); + if (javaFqn != null && !javaFqn.isEmpty()) { + typeInfo.setJavaFqn(javaFqn); + } + + // Parse type parameters + if (typeParamsStr != null && !typeParamsStr.isEmpty()) { + parseTypeParameters(typeParamsStr, typeInfo); + } + + if (extendsClause != null) { + // Handle multiple extends (e.g., "IEntityLivingBase, IAnimatable") + // Take the first one, stripping generics + String extendsType = extendsClause.trim(); + // Remove generic parameters like + extendsType = extendsType.replaceAll("<[^>]*>", ""); + // If multiple types (comma-separated), take the first one + if (extendsType.contains(",")) { + extendsType = extendsType.substring(0, extendsType.indexOf(',')).trim(); + } + // Clean up import() syntax if present + extendsType = cleanType(extendsType); + typeInfo.setExtends(extendsType); + } + + // Find the body of this interface + int bodyStart = interfaceMatcher.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd > bodyStart) { + String body = content.substring(bodyStart, bodyEnd); + parseInterfaceBody(body, typeInfo); + + // Parse nested interfaces and type aliases within this interface body + String fullNamespace = parentNamespace != null ? + parentNamespace + "." + interfaceName : interfaceName; + parseNestedTypes(body, fullNamespace, packageName); + } + + registry.registerType(typeInfo); + } + + // Find nested interfaces (without export keyword) + Matcher nestedInterfaceMatcher = NESTED_INTERFACE_PATTERN.matcher(scanContent); + while (nestedInterfaceMatcher.find()) { + String interfaceName = nestedInterfaceMatcher.group(1); + String typeParamsStr = nestedInterfaceMatcher.group(2); + String extendsClause = nestedInterfaceMatcher.group(3); + + JSTypeInfo typeInfo = new JSTypeInfo(interfaceName, parentNamespace); + + JSDocInfo jsDoc = extractJSDocBefore(content, nestedInterfaceMatcher.start()); + if (jsDoc != null) { + typeInfo.setJsDocInfo(jsDoc); + } + + // Parse type parameters + if (typeParamsStr != null && !typeParamsStr.isEmpty()) { + parseTypeParameters(typeParamsStr, typeInfo); + } + + if (extendsClause != null) { + String extendsType = extendsClause.trim(); + extendsType = extendsType.replaceAll("<[^>]*>", ""); + if (extendsType.contains(",")) { + extendsType = extendsType.substring(0, extendsType.indexOf(',')).trim(); + } + extendsType = cleanType(extendsType); + typeInfo.setExtends(extendsType); + } + + // Find the body of this nested interface + int bodyStart = nestedInterfaceMatcher.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd > bodyStart) { + String body = content.substring(bodyStart, bodyEnd); + parseInterfaceBody(body, typeInfo); + } + + registry.registerType(typeInfo); + } + + // Find classes (same logic as interfaces) + Matcher classMatcher = CLASS_PATTERN.matcher(scanContent); + while (classMatcher.find()) { + String className = classMatcher.group(1); + String typeParamsStr = classMatcher.group(2); + String extendsClause = classMatcher.group(3); + + JSTypeInfo typeInfo = new JSTypeInfo(className, parentNamespace); + + JSDocInfo jsDoc = extractJSDocBefore(content, classMatcher.start()); + if (jsDoc != null) { + typeInfo.setJsDocInfo(jsDoc); + } + + // Parse type parameters + if (typeParamsStr != null && !typeParamsStr.isEmpty()) { + parseTypeParameters(typeParamsStr, typeInfo); + } + + if (extendsClause != null) { + String extendsType = extendsClause.trim(); + extendsType = extendsType.replaceAll("<[^>]*>", ""); + if (extendsType.contains(",")) { + extendsType = extendsType.substring(0, extendsType.indexOf(',')).trim(); + } + extendsType = cleanType(extendsType); + typeInfo.setExtends(extendsType); + } + + // Find the body of this class + int bodyStart = classMatcher.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd > bodyStart) { + String body = content.substring(bodyStart, bodyEnd); + parseInterfaceBody(body, typeInfo); + } + + registry.registerType(typeInfo); + } + + // Find namespaces (which contain inner types) + Matcher namespaceMatcher = NAMESPACE_PATTERN.matcher(content); + while (namespaceMatcher.find()) { + String namespaceName = namespaceMatcher.group(1); + + // Find the body of this namespace + int bodyStart = namespaceMatcher.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd > bodyStart) { + String body = content.substring(bodyStart, bodyEnd); + + // Parse inner types with namespace prefix + // Namespaces contain exported types, so use parseInterfaceFile + String fullNamespace = parentNamespace != null ? + parentNamespace + "." + namespaceName : namespaceName; + parseInterfaceFile(body, fullNamespace, packageName); + // Don't call parseNestedTypes here - namespace members are all exported + // and will be caught by parseInterfaceFile's INTERFACE_PATTERN + } + } + + // Handle top-level type aliases + if (parentNamespace == null) { + parseTypeAliases(content, null); + } + } + + /** + * Strip namespace blocks from a file-level scan to avoid incorrectly capturing nested exports. + * + * This replaces the entire `namespace X { ... }` span (including braces) with whitespace so that + * match indices remain stable when we later slice `content` for bodies. + */ + private String stripNamespaceBlocks(String content) { + if (content == null || content.isEmpty()) { + return content; + } + + Matcher m = NAMESPACE_PATTERN.matcher(content); + if (!m.find()) { + return content; + } + + char[] chars = content.toCharArray(); + + int searchFrom = 0; + m.reset(); + while (m.find(searchFrom)) { + int start = m.start(); + int bodyStart = m.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd <= start) { + searchFrom = Math.max(m.end(), searchFrom + 1); + continue; + } + + int endExclusive = Math.min(chars.length, bodyEnd + 1); + for (int i = start; i < endExclusive; i++) { + chars[i] = ' '; + } + + searchFrom = endExclusive; + } + + return new String(chars); + } + + /** + * Parse type parameters from a string like "T extends EntityPlayerMP /* net.minecraft.entity.player.EntityPlayerMP *`/". + * Handles multiple parameters separated by commas. + * Stores strings only - resolution happens in Phase 2 after all types are loaded. + */ + private void parseTypeParameters(String typeParamsStr, JSTypeInfo typeInfo) { + // Split by comma, but be careful with nested generics (shouldn't happen at this level, but be safe) + String[] params = typeParamsStr.split(","); + for (String param : params) { + param = param.trim(); + if (param.isEmpty()) continue; + + Matcher m = TYPE_PARAM_PATTERN.matcher(param); + if (m.find()) { + String name = m.group(1); + String boundType = m.group(2); // Simple name like "EntityPlayerMP" + String fullBoundType = m.group(3); // Full name like "net.minecraft.entity.player.EntityPlayerMP" + + // Store strings only - resolution happens in Phase 2 + typeInfo.addTypeParam(new TypeParamInfo(name, boundType, fullBoundType)); + } + } + } + + /** + * Parse nested types (interfaces and type aliases) within a parent type or namespace. + */ + private void parseNestedTypes(String content, String namespace, String packageName) { + // Parse nested interfaces + Matcher nestedInterfaceMatcher = NESTED_INTERFACE_PATTERN.matcher(content); + while (nestedInterfaceMatcher.find()) { + String interfaceName = nestedInterfaceMatcher.group(1); + String typeParamsStr = nestedInterfaceMatcher.group(2); + String extendsClause = nestedInterfaceMatcher.group(3); + + JSTypeInfo typeInfo = new JSTypeInfo(interfaceName, namespace); + + JSDocInfo jsDoc = extractJSDocBefore(content, nestedInterfaceMatcher.start()); + if (jsDoc != null) { + typeInfo.setJsDocInfo(jsDoc); + } + + String javaFqn = findJavaFqn(jsDoc, packageName, typeInfo.getFullName()); + if (javaFqn != null && !javaFqn.isEmpty()) { + typeInfo.setJavaFqn(javaFqn); + } + + // Parse type parameters + if (typeParamsStr != null && !typeParamsStr.isEmpty()) { + parseTypeParameters(typeParamsStr, typeInfo); + } + + if (extendsClause != null) { + String extendsType = extendsClause.trim(); + extendsType = extendsType.replaceAll("<[^>]*>", ""); + if (extendsType.contains(",")) { + extendsType = extendsType.substring(0, extendsType.indexOf(',')).trim(); + } + extendsType = cleanType(extendsType); + typeInfo.setExtends(extendsType); + } + + // Find the body of this nested interface + int bodyStart = nestedInterfaceMatcher.end(); + int bodyEnd = findMatchingBrace(content, bodyStart - 1); + if (bodyEnd > bodyStart) { + String body = content.substring(bodyStart, bodyEnd); + parseInterfaceBody(body, typeInfo); + } + + registry.registerType(typeInfo); + } + + // Parse type aliases within this context + parseTypeAliases(content, namespace); + } + + private String findJavaFqn(JSDocInfo jsDoc, String packageName, String typeFullName) { + String tagged = extractJavaFqnFromJSDoc(jsDoc); + if (tagged != null && !tagged.isEmpty()) { + return tagged; + } + return buildJavaFqnFromPackage(packageName, typeFullName); + } + + private String extractJavaFqnFromJSDoc(JSDocInfo jsDoc) { + if (jsDoc == null) return null; + for (JSDocTag tag : jsDoc.getAllTags()) { + if (tag == null) continue; + if ("javaFqn".equals(tag.getTagName())) { + if (tag.getDescription() != null && !tag.getDescription().trim().isEmpty()) { + return tag.getDescription().trim(); + } + if (tag.getTypeName() != null && !tag.getTypeName().trim().isEmpty()) { + return tag.getTypeName().trim(); + } + } + } + return null; + } + + private String buildJavaFqnFromPackage(String packageName, String typeFullName) { + if (typeFullName == null || typeFullName.isEmpty()) return null; + if (packageName == null || packageName.isEmpty()) return null; + return packageName + "." + typeFullName; + } + + private String derivePackageName(String fileName) { + if (fileName == null || fileName.isEmpty()) return null; + String normalized = fileName.replace('\\', '/'); + if (normalized.endsWith(".d.ts")) { + normalized = normalized.substring(0, normalized.length() - 5); + } + int lastSlash = normalized.lastIndexOf('/'); + if (lastSlash < 0) return null; + String pkgPath = normalized.substring(0, lastSlash); + if (pkgPath.isEmpty()) return null; + return pkgPath.replace('/', '.'); + } + + /** + * Parse type aliases (export type X = Y). + */ + private void parseTypeAliases(String content, String namespace) { + Matcher m = TYPE_ALIAS_PATTERN.matcher(content); + while (m.find()) { + String aliasName = m.group(1); + String targetType = m.group(2).trim(); + + // Create a simple type that extends the target + JSTypeInfo typeInfo = new JSTypeInfo(aliasName, namespace); + typeInfo.setExtends(targetType); + registry.registerType(typeInfo); + } + } + + /** + * Parse the body of an interface to extract methods and fields. + */ + private void parseInterfaceBody(String body, JSTypeInfo typeInfo) { + // Parse methods + Matcher methodMatcher = METHOD_PATTERN.matcher(body); + while (methodMatcher.find()) { + String methodName = methodMatcher.group(1); + String params = methodMatcher.group(2); + String returnType = cleanType(methodMatcher.group(3)); + + List parameters = parseParameters(params); + JSMethodInfo method = new JSMethodInfo(methodName, returnType, parameters); + + JSDocInfo jsDoc = extractJSDocBefore(body, methodMatcher.start()); + if (jsDoc != null) { + method.setJsDocInfo(jsDoc); + } + + typeInfo.addMethod(method); + } + + // Parse fields (that aren't method signatures) + Matcher fieldMatcher = FIELD_PATTERN.matcher(body); + while (fieldMatcher.find()) { + String fieldText = fieldMatcher.group(0); + // Skip if this looks like a method (has parentheses in the match) + if (fieldText.contains("(")) continue; + + boolean readonly = fieldMatcher.group(1) != null; + String fieldName = fieldMatcher.group(2); + String fieldType = cleanType(fieldMatcher.group(3)); + + JSFieldInfo field = new JSFieldInfo(fieldName, fieldType, readonly); + + JSDocInfo jsDoc = extractJSDocBefore(body, fieldMatcher.start()); + if (jsDoc != null) { + field.setJsDocInfo(jsDoc); + } + + typeInfo.addField(field); + } + } + + /** + * Parse parameters string into list of parameter info. + */ + private List parseParameters(String params) { + List result = new ArrayList<>(); + if (params == null || params.trim().isEmpty()) { + return result; + } + + // Split by comma, but be careful of nested types like Map + List paramParts = splitParameters(params); + + for (String part : paramParts) { + part = part.trim(); + if (part.isEmpty()) continue; + + // Format: name: type or name?: type + int colonIndex = part.indexOf(':'); + if (colonIndex > 0) { + String name = part.substring(0, colonIndex).trim().replace("?", ""); + String type = cleanType(part.substring(colonIndex + 1).trim()); + result.add(new JSMethodInfo.JSParameterInfo(name, type)); + } + } + + return result; + } + + /** + * Split parameters respecting nested angle brackets. + */ + private List splitParameters(String params) { + List result = new ArrayList<>(); + int depth = 0; + int start = 0; + + for (int i = 0; i < params.length(); i++) { + char c = params.charAt(i); + if (c == '<' || c == '(' || c == '[') { + depth++; + } else if (c == '>' || c == ')' || c == ']') { + depth--; + } else if (c == ',' && depth == 0) { + result.add(params.substring(start, i)); + start = i + 1; + } + } + + if (start < params.length()) { + result.add(params.substring(start)); + } + + return result; + } + + /** + * Clean up a type string (remove import() syntax, trim, etc.). + */ + private String cleanType(String type) { + if (type == null) return "any"; + return TypeStringNormalizer.stripImportTypeSyntax(type); + } + + /** + * Find the matching closing brace. + */ + private int findMatchingBrace(String text, int openBracePos) { + int depth = 1; + for (int i = openBracePos + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '{') depth++; + else if (c == '}') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + /** + * Read entire content from reader. + */ + private String readFully(BufferedReader reader) throws IOException { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } + + private JSDocInfo extractJSDocBefore(String content, int elementStart) { + String jsDocBlock = DTSJSDocParser.extractJSDocBefore(content, elementStart); + if (jsDocBlock != null) { + return DTSJSDocParser.parseJSDocBlock(jsDocBlock); + } + return null; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocInfo.java new file mode 100644 index 000000000..899239884 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocInfo.java @@ -0,0 +1,110 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents parsed JSDoc comment information. + * Captures @type, @param, @return, and other JSDoc tags with their type information. + */ +public class JSDocInfo { + + private final String rawComment; + private final int startOffset; + private final int endOffset; + + private JSDocTypeTag typeTag; + private final List paramTags = new ArrayList<>(); + private JSDocReturnTag returnTag; + private final List seeTags = new ArrayList<>(); + private String description; + private final List allTags = new ArrayList<>(); + + public JSDocInfo(String rawComment, int startOffset, int endOffset) { + this.rawComment = rawComment; + this.startOffset = startOffset; + this.endOffset = endOffset; + } + + public void setTypeTag(JSDocTypeTag typeTag) { + this.typeTag = typeTag; + allTags.add(typeTag); + } + + public void addParamTag(JSDocParamTag paramTag) { + this.paramTags.add(paramTag); + allTags.add(paramTag); + } + + public void setReturnTag(JSDocReturnTag returnTag) { + this.returnTag = returnTag; + allTags.add(returnTag); + } + + + public void addSeeTag(JSDocSeeTag seeTag) { + this.seeTags.add(seeTag); + allTags.add(seeTag); + } + + public void setDescription(String description) { + this.description = description; + } + + public void addTag(JSDocTag tag) { + allTags.add(tag); + } + + public String getRawComment() { return rawComment; } + public int getStartOffset() { return startOffset; } + public int getEndOffset() { return endOffset; } + public JSDocTypeTag getTypeTag() { return typeTag; } + public boolean hasTypeTag() { return typeTag != null; } + + public TypeInfo getDeclaredType() { + return typeTag != null ? typeTag.getTypeInfo() : null; + } + + public List getParamTags() { + return Collections.unmodifiableList(paramTags); + } + + public boolean hasParamTags() { return !paramTags.isEmpty(); } + + public JSDocParamTag getParamTag(String paramName) { + for (JSDocParamTag tag : paramTags) { + String tagName = tag.getParamName(); + if (tagName != null && tagName.equals(paramName)) { + return tag; + } + } + return null; + } + + public JSDocReturnTag getReturnTag() { return returnTag; } + public boolean hasReturnTag() { return returnTag != null; } + + public TypeInfo getReturnType() { + return returnTag != null ? returnTag.getTypeInfo() : null; + } + + public List getSeeTags() { return Collections.unmodifiableList(seeTags); } + public boolean hasSeeTags() { return !seeTags.isEmpty(); } + + public String getDescription() { return description; } + public List getAllTags() { return Collections.unmodifiableList(allTags); } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("JSDocInfo{"); + if (typeTag != null) sb.append("type=").append(typeTag.getTypeName()); + if (!paramTags.isEmpty()) sb.append(", params=").append(paramTags.size()); + if (returnTag != null) sb.append(", return=").append(returnTag.getTypeName()); + if (!seeTags.isEmpty()) sb.append(", see=").append(seeTags.size()); + sb.append("}"); + return sb.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocParamTag.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocParamTag.java new file mode 100644 index 000000000..cdabdb717 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocParamTag.java @@ -0,0 +1,43 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +/** + * Represents an @param JSDoc tag. + * Used to declare the type and description of a function parameter. + * Example: @param {string} name - The name of the person + */ +public class JSDocParamTag extends JSDocTag { + + private final String paramName; + private final int paramNameStart; + private final int paramNameEnd; + + public JSDocParamTag(int atSignOffset, int tagNameStart, int tagNameEnd, + String paramName, int paramNameStart, int paramNameEnd) { + super("param", atSignOffset, tagNameStart, tagNameEnd); + this.paramName = paramName; + this.paramNameStart = paramNameStart; + this.paramNameEnd = paramNameEnd; + } + + public static JSDocParamTag create(int atSignOffset, int tagNameStart, int tagNameEnd, + String typeName, TypeInfo typeInfo, int typeStart, int typeEnd, + String paramName, int paramNameStart, int paramNameEnd, + String description) { + JSDocParamTag tag = new JSDocParamTag(atSignOffset, tagNameStart, tagNameEnd, + paramName, paramNameStart, paramNameEnd); + tag.setType(typeName, typeInfo, typeStart, typeEnd); + tag.setDescription(description); + return tag; + } + + public String getParamName() { return paramName; } + public int getParamNameStart() { return paramNameStart; } + public int getParamNameEnd() { return paramNameEnd; } + + @Override + public String toString() { + return "JSDocParamTag{@param " + (typeName != null ? "{" + typeName + "} " : "") + paramName + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocParser.java new file mode 100644 index 000000000..8692965db --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocParser.java @@ -0,0 +1,325 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses JSDoc comments to extract type information. + * Handles @type, @param, @return/@returns tags. + */ +public class JSDocParser { + + private static final Pattern JSDOC_PATTERN = Pattern.compile( + "/\\*\\*([\\s\\S]*?)\\*/", Pattern.MULTILINE); + + private static final Pattern TYPE_TAG_PATTERN = Pattern.compile( + "^@(type)\\s*(?:\\{([^}]+)\\})?(?:\\s*-?\\s*(.*))?$", Pattern.CASE_INSENSITIVE); + + private static final Pattern PARAM_TAG_PATTERN = Pattern.compile( + "^@param\\s*(?:\\{([^}]+)\\})?\\s*(\\w+)?(?:\\s*-?\\s*(.*))?$", Pattern.CASE_INSENSITIVE); + + private static final Pattern RETURN_TAG_PATTERN = Pattern.compile( + "^@(returns?)\\s*(?:\\{([^}]+)\\})?(?:\\s*-?\\s*(.*))?$", Pattern.CASE_INSENSITIVE); + + private static final Pattern GENERIC_TAG_PATTERN = Pattern.compile( + "^@(\\w+)(?:\\s*\\{([^}]+)\\})?(?:\\s*-?\\s*(.*))?$", Pattern.CASE_INSENSITIVE); + + private final ScriptDocument document; + + public JSDocParser(ScriptDocument typeResolver) { + this.document = typeResolver; + } + + public JSDocInfo parse(String comment, int commentStart) { + if (comment == null || !comment.startsWith("/**")) { + return null; + } + + int commentEnd = commentStart + comment.length(); + JSDocInfo info = new JSDocInfo(comment, commentStart, commentEnd); + + String content = comment.substring(3); + if (content.endsWith("*/")) { + content = content.substring(0, content.length() - 2); + } + + int contentOffset = commentStart + 3; + + StringBuilder descriptionBuilder = new StringBuilder(); + boolean foundFirstTag = false; + JSDocTag lastTag = null; + + int position = 0; + while (position <= content.length()) { + int lineEnd = content.indexOf('\n', position); + if (lineEnd == -1) { + lineEnd = content.length(); + } + + String rawLine = content.substring(position, lineEnd); + if (rawLine.endsWith("\r")) { + rawLine = rawLine.substring(0, rawLine.length() - 1); + } + + CleanLine cleanLine = cleanLine(rawLine, contentOffset + position); + String line = cleanLine.text; + + if (!line.isEmpty()) { + if (line.startsWith("@")) { + foundFirstTag = true; + lastTag = parseTagLine(line, cleanLine.offset, info); + } else if (!foundFirstTag) { + if (descriptionBuilder.length() > 0) { + descriptionBuilder.append(" "); + } + descriptionBuilder.append(line); + } else if (lastTag != null) { + String existing = lastTag.getDescription(); + if (existing == null || existing.isEmpty()) { + lastTag.setDescription(line); + } else { + lastTag.setDescription(existing + " " + line); + } + } + } + + if (lineEnd == content.length()) { + break; + } + position = lineEnd + 1; + } + + if (descriptionBuilder.length() > 0) { + info.setDescription(descriptionBuilder.toString().trim()); + } + + return info; + } + + public JSDocInfo extractJSDocBefore(String source, int position) { + if (source == null || position <= 0) { + return null; + } + + int searchStart = Math.max(0, position - 1); + + while (searchStart > 0 && Character.isWhitespace(source.charAt(searchStart))) { + searchStart--; + } + + int endCommentPos = -1; + if (searchStart >= 1 && source.charAt(searchStart) == '/' && source.charAt(searchStart - 1) == '*') { + endCommentPos = searchStart; + } else if (searchStart >= 1 && source.charAt(searchStart - 1) == '/' && searchStart > 1 && source.charAt( + searchStart - 2) == '*') { + endCommentPos = searchStart - 1; + } + + if (endCommentPos < 0) { + return null; + } + + int startCommentPos = -1; + for (int i = endCommentPos - 1; i >= 2; i--) { + if (source.charAt(i) == '*' && source.charAt(i - 1) == '*' && source.charAt(i - 2) == '/') { + startCommentPos = i - 2; + break; + } + } + + if (startCommentPos < 0) { + return null; + } + + String comment = source.substring(startCommentPos, endCommentPos + 1); + return parse(comment, startCommentPos); + } + + private JSDocTag parseTagLine(String line, int lineOffset, JSDocInfo info) { + Matcher paramMatcher = PARAM_TAG_PATTERN.matcher(line); + if (paramMatcher.matches()) { + String typeName = paramMatcher.group(1); + String paramName = paramMatcher.group(2); + String description = normalizeDescription(paramMatcher.group(3)); + + int atSignOffset = lineOffset + paramMatcher.start(); + int tagNameStart = lineOffset + 1; + int tagNameEnd = tagNameStart + "param".length(); + + int typeStart = -1; + int typeEnd = -1; + TypeInfo typeInfo = null; + + if (typeName != null) { + typeName = typeName.trim(); + typeStart = lineOffset + paramMatcher.start(1); + typeEnd = lineOffset + paramMatcher.end(1); + typeInfo = resolveType(typeName); + } + + int paramNameStart = -1; + int paramNameEnd = -1; + if (paramName != null) { + paramNameStart = lineOffset + paramMatcher.start(2); + paramNameEnd = lineOffset + paramMatcher.end(2); + } + + JSDocParamTag tag = JSDocParamTag.create(atSignOffset, tagNameStart, tagNameEnd, + typeName, typeInfo, typeStart, typeEnd, + paramName, paramNameStart, paramNameEnd, + description); + info.addParamTag(tag); + return tag; + } + + Matcher returnMatcher = RETURN_TAG_PATTERN.matcher(line); + if (returnMatcher.matches()) { + String tagName = returnMatcher.group(1); + String typeName = returnMatcher.group(2); + String description = normalizeDescription(returnMatcher.group(3)); + + int atSignOffset = lineOffset + returnMatcher.start(); + int tagNameStart = lineOffset + returnMatcher.start(1); + int tagNameEnd = lineOffset + returnMatcher.end(1); + + int typeStart = -1; + int typeEnd = -1; + TypeInfo typeInfo = null; + + if (typeName != null) { + typeName = typeName.trim(); + typeStart = lineOffset + returnMatcher.start(2); + typeEnd = lineOffset + returnMatcher.end(2); + typeInfo = resolveType(typeName); + } + + JSDocReturnTag tag = JSDocReturnTag.create(tagName, atSignOffset, tagNameStart, tagNameEnd, + typeName, typeInfo, typeStart, typeEnd, + description); + info.setReturnTag(tag); + return tag; + } + + Matcher typeMatcher = TYPE_TAG_PATTERN.matcher(line); + if (typeMatcher.matches()) { + String typeName = typeMatcher.group(2); + String description = normalizeDescription(typeMatcher.group(3)); + + int atSignOffset = lineOffset + typeMatcher.start(); + int tagNameStart = lineOffset + typeMatcher.start(1); + int tagNameEnd = lineOffset + typeMatcher.end(1); + + int typeStart = -1; + int typeEnd = -1; + TypeInfo typeInfo = null; + + if (typeName != null) { + typeName = typeName.trim(); + typeStart = lineOffset + typeMatcher.start(2); + typeEnd = lineOffset + typeMatcher.end(2); + typeInfo = resolveType(typeName); + } + + JSDocTypeTag tag = JSDocTypeTag.create(atSignOffset, tagNameStart, tagNameEnd, + typeName, typeInfo, typeStart, typeEnd, description); + info.setTypeTag(tag); + return tag; + } + + Matcher genericMatcher = GENERIC_TAG_PATTERN.matcher(line); + if (genericMatcher.matches()) { + String tagName = genericMatcher.group(1); + String typeName = genericMatcher.group(2); + String description = normalizeDescription(genericMatcher.group(3)); + + int atSignOffset = lineOffset + genericMatcher.start(); + int tagNameStart = lineOffset + genericMatcher.start(1); + int tagNameEnd = lineOffset + genericMatcher.end(1); + + JSDocTag tag = new JSDocTag(tagName, atSignOffset, tagNameStart, tagNameEnd); + + if (typeName != null) { + typeName = typeName.trim(); + int typeStart = lineOffset + genericMatcher.start(2); + int typeEnd = lineOffset + genericMatcher.end(2); + TypeInfo typeInfo = resolveType(typeName); + tag.setType(typeName, typeInfo, typeStart, typeEnd); + } + + if (description != null && !description.isEmpty()) { + tag.setDescription(description); + } + + info.addTag(tag); + return tag; + } + + return null; + } + + private String normalizeDescription(String description) { + if (description == null) { + return null; + } + String trimmed = description.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private CleanLine cleanLine(String line, int lineStartOffset) { + int index = 0; + while (index < line.length() && Character.isWhitespace(line.charAt(index))) { + index++; + } + + if (index + 2 < line.length() && line.startsWith("/**", index)) { + index += 3; + while (index < line.length() && Character.isWhitespace(line.charAt(index))) { + index++; + } + } + + if (index < line.length() && line.charAt(index) == '*') { + index++; + if (index < line.length() && line.charAt(index) == ' ') { + index++; + } + } + + String cleaned = line.substring(index); + int end = cleaned.length(); + while (end > 0 && Character.isWhitespace(cleaned.charAt(end - 1))) { + end--; + } + cleaned = cleaned.substring(0, end); + + return new CleanLine(cleaned, lineStartOffset + index); + } + + private TypeInfo resolveType(String typeName) { + return document.resolveType(typeName); + } + + private static class CleanLine { + private final String text; + private final int offset; + + private CleanLine(String text, int offset) { + this.text = text; + this.offset = offset; + } + } + + public static List findAllJSDocComments(String source) { + List positions = new ArrayList<>(); + Matcher m = JSDOC_PATTERN.matcher(source); + while (m.find()) { + positions.add(new int[]{m.start(), m.end()}); + } + return positions; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocReturnTag.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocReturnTag.java new file mode 100644 index 000000000..0f5fca416 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocReturnTag.java @@ -0,0 +1,29 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +/** + * Represents an @return or @returns JSDoc tag. + * Used to declare the return type of a function. + * Example: @return {number} The calculated result + */ +public class JSDocReturnTag extends JSDocTag { + + public JSDocReturnTag(String tagName, int atSignOffset, int tagNameStart, int tagNameEnd) { + super(tagName, atSignOffset, tagNameStart, tagNameEnd); + } + + public static JSDocReturnTag create(String tagName, int atSignOffset, int tagNameStart, int tagNameEnd, + String typeName, TypeInfo typeInfo, int typeStart, int typeEnd, + String description) { + JSDocReturnTag tag = new JSDocReturnTag(tagName, atSignOffset, tagNameStart, tagNameEnd); + tag.setType(typeName, typeInfo, typeStart, typeEnd); + tag.setDescription(description); + return tag; + } + + @Override + public String toString() { + return "JSDocReturnTag{@" + tagName + " " + (typeName != null ? "{" + typeName + "}" : "") + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocSeeTag.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocSeeTag.java new file mode 100644 index 000000000..028023c6e --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocSeeTag.java @@ -0,0 +1,75 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +public class JSDocSeeTag extends JSDocTag { + + private final String reference; + private String url; + private String linkText; + + public JSDocSeeTag(String tagName, int atSignOffset, int tagNameStart, int tagNameEnd, String reference) { + super(tagName, atSignOffset, tagNameStart, tagNameEnd); + this.reference = reference; + this.setDescription(reference); + parseReference(reference); + } + + public static JSDocSeeTag create(String tagName, int atSignOffset, int tagNameStart, int tagNameEnd, + String reference) { + return new JSDocSeeTag(tagName, atSignOffset, tagNameStart, tagNameEnd, reference); + } + + public static JSDocSeeTag createSimple(String reference) { + return new JSDocSeeTag("see", -1, -1, -1, reference); + } + + private void parseReference(String ref) { + if (ref == null) return; + + if (ref.contains(""); + int textEnd = ref.indexOf(""); + if (textStart != -1 && textEnd != -1 && textStart < textEnd) { + this.linkText = ref.substring(textStart + 1, textEnd); + } + } else if (ref.contains("{@link")) { + int linkStart = ref.indexOf("{@link"); + int linkEnd = ref.indexOf("}", linkStart); + if (linkStart != -1 && linkEnd != -1) { + this.linkText = ref.substring(linkStart + 6, linkEnd).trim(); + } + } + } + + public String getReference() { + return reference; + } + + public String getUrl() { + return url; + } + + public boolean hasUrl() { + return url != null && !url.isEmpty(); + } + + public String getLinkText() { + return linkText; + } + + public boolean hasLinkText() { + return linkText != null && !linkText.isEmpty(); + } + + @Override + public String toString() { + return "JSDocSeeTag{@see " + reference + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocTag.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocTag.java new file mode 100644 index 000000000..82fd1edbe --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocTag.java @@ -0,0 +1,55 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +/** + * Base class for JSDoc tags. + * Each tag stores its position information for syntax highlighting. + */ +public class JSDocTag { + + protected final String tagName; + protected final int atSignOffset; + protected final int tagNameStart; + protected final int tagNameEnd; + + protected String typeName; + protected TypeInfo typeInfo; + protected int typeStart = -1; + protected int typeEnd = -1; + protected String description; + + public JSDocTag(String tagName, int atSignOffset, int tagNameStart, int tagNameEnd) { + this.tagName = tagName; + this.atSignOffset = atSignOffset; + this.tagNameStart = tagNameStart; + this.tagNameEnd = tagNameEnd; + } + + public void setType(String typeName, TypeInfo typeInfo, int typeStart, int typeEnd) { + this.typeName = typeName; + this.typeInfo = typeInfo; + this.typeStart = typeStart; + this.typeEnd = typeEnd; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getTagName() { return tagName; } + public int getAtSignOffset() { return atSignOffset; } + public int getTagNameStart() { return tagNameStart; } + public int getTagNameEnd() { return tagNameEnd; } + public String getTypeName() { return typeName; } + public TypeInfo getTypeInfo() { return typeInfo; } + public boolean hasType() { return typeName != null && !typeName.isEmpty(); } + public int getTypeStart() { return typeStart; } + public int getTypeEnd() { return typeEnd; } + public String getDescription() { return description; } + + @Override + public String toString() { + return "JSDocTag{@" + tagName + (typeName != null ? " {" + typeName + "}" : "") + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocTypeTag.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocTypeTag.java new file mode 100644 index 000000000..1f27f6667 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/jsdoc/JSDocTypeTag.java @@ -0,0 +1,28 @@ +package noppes.npcs.client.gui.util.script.interpreter.jsdoc; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +/** + * Represents an @type JSDoc tag. + * Used to declare the type of a variable. + * Example: @type {string} + */ +public class JSDocTypeTag extends JSDocTag { + + public JSDocTypeTag(int atSignOffset, int tagNameStart, int tagNameEnd) { + super("type", atSignOffset, tagNameStart, tagNameEnd); + } + + public static JSDocTypeTag create(int atSignOffset, int tagNameStart, int tagNameEnd, String typeName, + TypeInfo typeInfo, int typeStart, int typeEnd, String description) { + JSDocTypeTag tag = new JSDocTypeTag(atSignOffset, tagNameStart, tagNameEnd); + tag.setType(typeName, typeInfo, typeStart, typeEnd); + tag.setDescription(description); + return tag; + } + + @Override + public String toString() { + return "JSDocTypeTag{@type " + (typeName != null ? "{" + typeName + "}" : "") + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodCallInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodCallInfo.java new file mode 100644 index 000000000..7480ad466 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodCallInfo.java @@ -0,0 +1,425 @@ +package noppes.npcs.client.gui.util.script.interpreter.method; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.Token; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Stores information about a method call for argument validation. + * This includes the method name, the arguments passed, and validation results. + */ +public class MethodCallInfo { + + /** + * Represents a single argument in a method call. + */ + public static class Argument { + private final String text; // The text of the argument expression + private final int startOffset; // Start position in source + private final int endOffset; // End position in source + private final TypeInfo resolvedType; // The resolved type of the argument (null if unresolved) + private final boolean valid; // Whether this arg matches the expected parameter type + private final String errorMessage; // Error message if invalid + + public Argument(String text, int startOffset, int endOffset, TypeInfo resolvedType, + boolean valid, String errorMessage) { + this.text = text; + this.startOffset = startOffset; + this.endOffset = endOffset; + this.resolvedType = resolvedType; + this.valid = valid; + this.errorMessage = errorMessage; + } + + public String getText() { + return text; + } + + public int getStartOffset() { + return startOffset; + } + + public int getEndOffset() { + return endOffset; + } + + public TypeInfo getResolvedType() { + return resolvedType; + } + + public boolean isValid() { + return valid; + } + + public String getErrorMessage() { + return errorMessage; + } + + public boolean equals(Token t) { + return text.equals(t.getText()) && + startOffset == t.getGlobalStart() && + endOffset == t.getGlobalEnd(); + } + + @Override + public String toString() { + return "Arg{" + text + " [" + startOffset + "-" + endOffset + "]" + + (resolvedType != null ? " :" + resolvedType.getSimpleName() : "") + + (valid ? "" : " INVALID: " + errorMessage) + "}"; + } + } + /** + * Validation error type. + */ + public enum ErrorType { + NONE, + WRONG_ARG_COUNT, // Number of args doesn't match any overload + WRONG_ARG_TYPE, // Specific argument has wrong type + STATIC_ACCESS_ERROR, // Trying to call instance method statically or vice versa + RETURN_TYPE_MISMATCH, // Return type doesn't match expected type (e.g., assignment LHS) + UNRESOLVED_METHOD, // Method doesn't exist + UNRESOLVED_RECEIVER // Can't resolve the receiver type + } + + private final String methodName; + private final int methodNameStart; // Position of method name + private final int methodNameEnd; + private final int openParenOffset; // Position of '(' + private final int closeParenOffset; // Position of ')' + private final List arguments; + private final TypeInfo receiverType; // The type on which this method is called (null for standalone) + private final MethodInfo resolvedMethod; // The resolved method (null if unresolved) + private final boolean isStaticAccess; // True if this is Class.method() style access + private TypeInfo expectedType; // Expected return type (from assignment LHS, etc.) + + private ErrorType errorType = ErrorType.NONE; + private String errorMessage; + private int errorArgIndex = -1; // Index of the problematic argument (for WRONG_ARG_TYPE) + private List argumentTypeErrors = new ArrayList<>(); + + private boolean isConstructor; + + public MethodCallInfo(String methodName, int methodNameStart, int methodNameEnd, + int openParenOffset, int closeParenOffset, + List arguments, TypeInfo receiverType, + MethodInfo resolvedMethod) { + this(methodName, methodNameStart, methodNameEnd, openParenOffset, closeParenOffset, + arguments, receiverType, resolvedMethod, false); + } + + public MethodCallInfo(String methodName, int methodNameStart, int methodNameEnd, + int openParenOffset, int closeParenOffset, + List arguments, TypeInfo receiverType, + MethodInfo resolvedMethod, boolean isStaticAccess) { + this.methodName = methodName; + this.methodNameStart = methodNameStart; + this.methodNameEnd = methodNameEnd; + this.openParenOffset = openParenOffset; + this.closeParenOffset = closeParenOffset; + this.arguments = arguments != null ? new ArrayList<>(arguments) : new ArrayList<>(); + this.receiverType = receiverType; + this.resolvedMethod = resolvedMethod; + this.isStaticAccess = isStaticAccess; + } + + /** + * Factory method to create a MethodCallInfo for a constructor call. + * Constructors are represented as method calls where the type itself is the receiver. + */ + public static MethodCallInfo constructor(TypeInfo typeInfo, MethodInfo constructor, + int typeNameStart, int typeNameEnd, + int openParenOffset, int closeParenOffset, + List arguments) { + return new MethodCallInfo( + typeInfo.getSimpleName(), // Constructor name is the type name + typeNameStart, + typeNameEnd, + openParenOffset, + closeParenOffset, + arguments, + typeInfo, // The type itself is the receiver + constructor, // The constructor MethodInfo + false // Constructors are not static access + ).setConstructor(true); + } + + // Getters + public String getMethodName() { + return methodName; + } + + public int getMethodNameStart() { + return methodNameStart; + } + + public int getMethodNameEnd() { + return methodNameEnd; + } + + public int getOpenParenOffset() { + return openParenOffset; + } + + public int getCloseParenOffset() { + return closeParenOffset; + } + + public List getArguments() { + return Collections.unmodifiableList(arguments); + } + + public int getArgumentCount() { + return arguments.size(); + } + + public TypeInfo getReceiverType() { + return receiverType; + } + + public MethodInfo getResolvedMethod() { + return resolvedMethod; + } + + public boolean isStaticAccess() { + return isStaticAccess; + } + + public TypeInfo getExpectedType() { + return expectedType; + } + + public void setExpectedType(TypeInfo expectedType) { + this.expectedType = expectedType; + } + + // Dynamically resolved return type (e.g., for Java.type() which returns ClassTypeInfo) + private TypeInfo resolvedReturnType; + + /** + * Get the resolved return type of this method call. + * This may differ from resolvedMethod.getReturnType() for methods with dynamic return types. + * @return The resolved return type, or the method's declared return type if not dynamically resolved + */ + public TypeInfo getResolvedReturnType() { + if (resolvedReturnType != null) { + return resolvedReturnType; + } + return resolvedMethod != null ? resolvedMethod.getReturnType() : null; + } + + /** + * Set a dynamically resolved return type. + * Used for methods like Java.type() where the return type depends on the arguments. + */ + public void setResolvedReturnType(TypeInfo returnType) { + this.resolvedReturnType = returnType; + } + + public boolean isConstructor() { + return isConstructor; + } + + public MethodCallInfo setConstructor(boolean isConstructor) { + this.isConstructor = isConstructor; + return this; + } + + public ErrorType getErrorType() { + return errorType; + } + + public String getErrorMessage() { + return errorMessage; + } + + /** + * Get the full span of the method call including parentheses. + * Used for underlining the entire call on arg count errors. + */ + public int getFullCallStart() { + return methodNameStart; + } + + public int getFullCallEnd() { + return closeParenOffset + 1; + } + + /** + * Check if this method call has any validation errors. + */ + public boolean hasError() { + return errorType != ErrorType.NONE; + } + + /** + * Check if this is an arg count error (underline whole call). + */ + public boolean hasArgCountError() { + return errorType == ErrorType.WRONG_ARG_COUNT; + } + + /** + * Check if this is an arg type error (underline specific arg). + */ + public boolean hasArgTypeError() { + return !this.argumentTypeErrors.isEmpty(); + } + + + /** + * Check if this is a static access error (underline method name). + */ + public boolean hasStaticAccessError() { + return errorType == ErrorType.STATIC_ACCESS_ERROR; + } + + /** + * Check if this is a return type mismatch error. + */ + public boolean hasReturnTypeMismatch() { + return errorType == ErrorType.RETURN_TYPE_MISMATCH; + } + + // Setters for validation results + public void setError(ErrorType type, String message) { + this.errorType = type; + this.errorMessage = message; + } + + public void setArgTypeError(int argIndex, String message) { + this.argumentTypeErrors.add(new ArgumentTypeError(arguments.get(argIndex), argIndex, message)); + } + + public List getArgumentTypeErrors() { + return argumentTypeErrors; + } + + public class ArgumentTypeError { + private ErrorType type = ErrorType.WRONG_ARG_TYPE; + private final Argument arg; + private final int argIndex; + private final String message; + + public ArgumentTypeError(Argument arg, int argIndex, String message) { + this.arg = arg; + this.argIndex = argIndex; + this.message = message; + } + + public int getArgIndex() { + return argIndex; + } + + public String getMessage() { + return message; + } + + public Argument getArg() { + return arg; + } + } + + /** + * Validate this method call against the resolved method signature. + * Sets error information if validation fails. + */ + public void validate() { + if (isConstructor) { + if (resolvedMethod == null) { + // For constructors, check if the type has any constructors at all + if (receiverType != null && receiverType.hasConstructors()) { + setError(ErrorType.WRONG_ARG_COUNT, + "No constructor in '" + receiverType.getSimpleName() + "' matches " + arguments.size() + " argument(s)"); + } else { + setError(ErrorType.UNRESOLVED_METHOD, + "Cannot resolve constructor for '" + methodName + "'"); + } + } else if (receiverType != null && arguments.size() == resolvedMethod.getParameterCount()) { + validateArgTypeError(); + } + return; + } + + // Check static/instance access (skip for constructors) + // NO LONGER CHECKED HERE, BUT DIRECTLY AT MARK CREATION + if (!isConstructor && isStaticAccess && !resolvedMethod.isStatic()) { + setError(ErrorType.STATIC_ACCESS_ERROR, + "Cannot call instance method '" + methodName + "' on a class type"); + return; + } + + List params = resolvedMethod.getParameters(); + int expectedCount = params.size(); + int actualCount = arguments.size(); + + // Check arg count + if (actualCount != expectedCount) { + setError(ErrorType.WRONG_ARG_COUNT, + "Expected " + expectedCount + " argument(s) but got " + actualCount); + return; + } + + // Check each argument type + validateArgTypeError(); + + // Check return type compatibility with expected type (e.g., assignment LHS) + // ALREADY CHECKED AT AssignmentInfo LEVEL + if (expectedType != null && resolvedMethod != null) { + TypeInfo returnType = resolvedMethod.getReturnType(); + if (returnType != null && !TypeChecker.isTypeCompatible(expectedType, returnType)) { + // setError(ErrorType.RETURN_TYPE_MISMATCH, + // "Required type: " + expectedType.getSimpleName() + ", Provided: " + returnType.getSimpleName()); + } + } + } + + public void validateArgTypeError() { + // Check each argument type + for (int i = 0; i < arguments.size(); i++) { + Argument arg = arguments.get(i); + + // Surface argument-level errors (e.g., SAM conflict, ambiguous overload, bare method in Java) + if (!arg.isValid() && arg.getErrorMessage() != null) { + setArgTypeError(i, arg.getErrorMessage()); + continue; // Skip remaining checks for this argument + } + + FieldInfo para = resolvedMethod.getParameters().get(i); + + TypeInfo argType = arg.getResolvedType(); + TypeInfo paramType = para.getTypeInfo(); + if (argType != null && paramType != null) { + if (!TypeChecker.isTypeCompatible(paramType, argType)) { + setArgTypeError(i, "Expected " + paramType.getDisplayName() + + " but got " + argType.getDisplayName()); + } + } else if (paramType == null) { + setArgTypeError(i, "Parameter type of '" + para.getName() + "' is unresolved"); + } else if (argType == null) { + setArgTypeError(i, "Cannot resolve type of argument '" + arg.getText() + "'"); + } + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("MethodCallInfo{"); + sb.append(methodName).append("("); + for (int i = 0; i < arguments.size(); i++) { + if (i > 0) + sb.append(", "); + sb.append(arguments.get(i).getText()); + } + sb.append(")"); + if (hasError()) { + sb.append(" ERROR: ").append(errorType).append(" - ").append(errorMessage); + } + sb.append("}"); + return sb.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java new file mode 100644 index 000000000..d780859d8 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java @@ -0,0 +1,725 @@ +package noppes.npcs.client.gui.util.script.interpreter.method; + +import 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.bridge.DtsJavaBridge; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSMethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.GenericContext; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeSubstitutor; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; + +/** + * Metadata for a method declaration or method call. + * Tracks method name, parameters, return type, and containing class. + * + *

This class delegates to helper classes for specific functionality:

+ *
    + *
  • {@link ControlFlowAnalyzer} - Control flow analysis for missing return detection
  • + *
  • {@link CodeParser} - Code parsing utilities (brace matching, comment removal)
  • + *
  • {@link TypeChecker} - Type compatibility checking
  • + *
+ */ +public final class MethodInfo { + + /** + * Validation error types for method declarations. + */ + public enum ErrorType { + NONE, + MISSING_RETURN, // Non-void method missing return statement, + INTERFACE_METHOD_BODY, // Interface method has a body + MISSING_BODY, // Non-void method missing return statement + RETURN_TYPE_MISMATCH, // Return statement type doesn't match method return type + VOID_METHOD_RETURNS_VALUE, // Void method returns a value + DUPLICATE_METHOD, // Method with same signature already defined in scope + DUPLICATE_PARAMETER, // Two parameters have the same name + PARAMETER_UNDEFINED, // Parameter type cannot be resolved + SAM_TYPE_INCOMPATIBLE // Explicit param type incompatible with SAM context + } + + private final String name; + private final TypeInfo returnType; + private final TypeInfo containingType; // The class/interface that owns this method + private final List parameters; + private final int fullDeclarationOffset; // Start of full declaration (including modifiers), -1 for external + private final int typeOffset; // Start of return type + private final int nameOffset; // Start of method name + private final int bodyStart; // Start of method body (after {) + private final int bodyEnd; // End of method body (before }) + private final boolean resolved; + private final boolean isDeclaration; // true if this is a declaration, false if it's a call + private final int modifiers; // Java Modifier flags (e.g., Modifier.PUBLIC | Modifier.STATIC) + private final String documentation; // Javadoc/comment documentation for this method + private JSDocInfo jsDocInfo; // Parsed JSDoc info for this method (may be null) + private final java.lang.reflect.Method javaMethod; // The Java reflection Method, if this was created from reflection + + // Error tracking for method declarations + private ErrorType errorType = ErrorType.NONE; + private String errorMessage; + private List parameterErrors = new ArrayList<>(); + private List returnStatementErrors = new ArrayList<>(); + + // ==================== OVERRIDE/IMPLEMENTS TRACKING ==================== + + /** + * The type containing the method that this method overrides (the super class). + * Null if this method doesn't override anything. + */ + private TypeInfo overridesFrom; + + /** + * The type containing the interface method that this method implements. + * Null if this method doesn't implement an interface method. + */ + private TypeInfo implementsFrom; + + private MethodInfo(String name, TypeInfo returnType, TypeInfo containingType, + List parameters, int fullDeclarationOffset, int typeOffset, int nameOffset, + int bodyStart, int bodyEnd, boolean resolved, boolean isDeclaration, + int modifiers, String documentation, java.lang.reflect.Method javaMethod) { + this.name = name; + this.returnType = returnType; + this.containingType = containingType; + this.parameters = parameters != null ? new ArrayList<>(parameters) : new ArrayList<>(); + this.fullDeclarationOffset = fullDeclarationOffset; + this.typeOffset = typeOffset; + this.nameOffset = nameOffset; + this.bodyStart = bodyStart; + this.bodyEnd = bodyEnd; + this.resolved = resolved; + this.isDeclaration = isDeclaration; + this.modifiers = modifiers; + this.documentation = documentation; + this.javaMethod = javaMethod; + } + + public static MethodInfo declaration(String name, TypeInfo containingType, TypeInfo returnType, List params, + int fullDeclOffset, int typeOffset, int nameOffset, + int bodyStart, int bodyEnd, int modifiers, String documentation) { + return new MethodInfo(name, returnType, containingType, params, fullDeclOffset, typeOffset, nameOffset, bodyStart, bodyEnd, true, true, modifiers, documentation, null); + } + + public static MethodInfo call(String name, TypeInfo containingType, int paramCount) { + boolean resolved = containingType != null && containingType.isResolved() && + containingType.hasMethod(name); + List params = new ArrayList<>(); + for (int i = 0; i < paramCount; i++) { + params.add(FieldInfo.unresolved("arg" + i, FieldInfo.Scope.PARAMETER)); + } + return new MethodInfo(name, null, containingType, params, -1, -1, -1, -1, -1, resolved, false, 0, null, null); + } + + public static MethodInfo unresolvedCall(String name, int paramCount) { + List params = new ArrayList<>(); + for (int i = 0; i < paramCount; i++) { + params.add(FieldInfo.unresolved("arg" + i, FieldInfo.Scope.PARAMETER)); + } + return new MethodInfo(name, null, null, params, -1, -1, -1, -1, -1, false, false, 0, null, null); + } + + /** + * Create a MethodInfo for a synthetic/external method. + * Used for built-in types like Nashorn's Java object. + */ + public static MethodInfo external(String name, TypeInfo returnType, TypeInfo containingType, + List params, int modifiers, String documentation) { + return new MethodInfo(name, returnType, containingType, params, -1, -1, -1, -1, -1, true, false, modifiers, documentation, null); + } + + /** + * Create a MethodInfo from reflection data. + * Used when resolving method calls on known types. + * Preserves generic type information from reflection. + */ + public static MethodInfo fromReflection(Method method, TypeInfo containingType) { + String name = method.getName(); + // Use getGenericReturnType() to preserve generic information like List + TypeInfo returnType = TypeInfo.fromGenericType(method.getGenericReturnType()); + if (returnType == null) { + returnType = TypeInfo.fromClass(method.getReturnType()); + } + int modifiers = method.getModifiers(); + + // Try to find matching JSMethodInfo to bridge parameter names/docs and allow return overrides + JSMethodInfo jsMethod = DtsJavaBridge.findMatchingMethod(method, containingType); + if (jsMethod != null) { + // If .d.ts provides a more specific return type, use it for editor typing. + // (e.g., .d.ts override like IDBCPlayer -> IDBCAddon in npcdbc). + TypeInfo overrideReturnType = DtsJavaBridge.resolveReturnTypeOverride(method, containingType, jsMethod); + if (overrideReturnType != null && overrideReturnType.getJavaClass() != null) { + Class reflectedReturn = method.getReturnType(); + Class overrideClass = overrideReturnType.getJavaClass(); + if (reflectedReturn.isAssignableFrom(overrideClass)) { + if (reflectedReturn != overrideClass) { + returnType = overrideReturnType; + System.out.println("[DtsJavaBridge] Overrode return type for " + + method.getDeclaringClass().getName() + "." + name + + "(" + method.getParameterCount() + ") from " + + reflectedReturn.getName() + " to " + overrideClass.getName()); + } + } + } + } + + // If the receiver is parameterized (e.g., List), substitute class type variables (E -> String) + Map receiverBindings = null; + if (GenericContext.hasGenerics(containingType)) { + receiverBindings = TypeSubstitutor.createBindingsFromReceiver(containingType); + if (!receiverBindings.isEmpty()) { + returnType = TypeSubstitutor.substitute(returnType, receiverBindings); + } + } + List params = new ArrayList<>(); + // Use getGenericParameterTypes() to preserve generic information + java.lang.reflect.Type[] genericParamTypes = method.getGenericParameterTypes(); + List jsParams = jsMethod != null ? jsMethod.getParameters() : null; + for (int i = 0; i < genericParamTypes.length; i++) { + TypeInfo paramType = TypeInfo.fromGenericType(genericParamTypes[i]); + if (paramType == null) { + paramType = TypeInfo.fromClass(method.getParameterTypes()[i]); + } + + if (receiverBindings != null && !receiverBindings.isEmpty()) { + paramType = TypeSubstitutor.substitute(paramType, receiverBindings); + } + String paramName = "arg" + i; + if (jsParams != null && i < jsParams.size()) { + String jsName = jsParams.get(i).getName(); + if (jsName != null && !jsName.isEmpty()) { + paramName = jsName; + } + } + params.add(FieldInfo.reflectionParam(paramName, paramType)); + } + + String documentation = null; + JSDocInfo jsDocInfo = null; + if (jsMethod != null) { + jsDocInfo = jsMethod.getJsDocInfo(); + String jsDocDesc = jsDocInfo != null ? jsDocInfo.getDescription() : null; + documentation = jsDocDesc != null ? jsDocDesc : jsMethod.getDocumentation(); + } + + MethodInfo methodInfo = new MethodInfo(name, returnType, containingType, params, -1, -1, -1, -1, -1, true, false, modifiers, documentation, method); + if (jsDocInfo != null) { + methodInfo.setJSDocInfo(jsDocInfo); + } + return methodInfo; + } + + /** + * Create a MethodInfo from a Constructor via reflection. + * Used when resolving constructor calls on external types. + * Preserves generic type information for constructor parameters. + */ + public static MethodInfo fromReflectionConstructor(Constructor constructor, TypeInfo containingType) { + String name = containingType.getSimpleName(); // Constructor name is the type name + TypeInfo returnType = containingType; // Constructor "returns" an instance of the type + int modifiers = constructor.getModifiers(); + + List params = new ArrayList<>(); + // Use getGenericParameterTypes() to preserve generic information + java.lang.reflect.Type[] genericParamTypes = constructor.getGenericParameterTypes(); + for (int i = 0; i < genericParamTypes.length; i++) { + TypeInfo paramType = TypeInfo.fromGenericType(genericParamTypes[i]); + if (paramType == null) { + paramType = TypeInfo.fromClass(constructor.getParameterTypes()[i]); + } + params.add(FieldInfo.reflectionParam("arg" + i, paramType)); + } + + return new MethodInfo(name, returnType, containingType, params, -1, -1, -1, -1, -1, true, true, modifiers, null, null); + } + + /** + * Create a MethodInfo from a JSMethodInfo (parsed from .d.ts files). + * Used when resolving method calls on JavaScript types. + * + * @param jsMethod The JavaScript method info from the type registry + * @param containingType The TypeInfo that owns this method + * @return A MethodInfo representing the JavaScript method + */ + public static MethodInfo fromJSMethod(JSMethodInfo jsMethod, TypeInfo containingType) { + String name = jsMethod.getName(); + + // Use the new getResolvedReturnType method + TypeInfo returnType = jsMethod.getResolvedReturnType(containingType); + + // Convert JS parameters to FieldInfo + List params = new ArrayList<>(); + List jsParams = jsMethod.getParameters(); + + for (JSMethodInfo.JSParameterInfo param : jsParams) { + String paramName = param.getName(); + + // Use the new getResolvedType method + TypeInfo paramType = param.getResolvedType(containingType); + + params.add(FieldInfo.reflectionParam(paramName, paramType)); + } + + // JS methods are always public (no access modifiers in .d.ts) + int modifiers = Modifier.PUBLIC; + + // Use the documentation from the method if available + JSDocInfo jsDocInfo = jsMethod.getJsDocInfo(); + String jsDocDesc = jsDocInfo != null ? jsDocInfo.getDescription() : null; + String documentation = jsDocDesc != null ? jsDocDesc : jsMethod.getDocumentation(); + + MethodInfo methodInfo = new MethodInfo(name, returnType, containingType, params, -1, -1, -1, -1, -1, true, + false, modifiers, documentation, null); + methodInfo.setJSDocInfo(jsDocInfo); + return methodInfo; + } + + + + // Getters + public String getName() { return name; } + public TypeInfo getReturnType() { return returnType; } + public TypeInfo getContainingType() { return containingType; } + public List getParameters() { return Collections.unmodifiableList(parameters); } + public int getParameterCount() { return parameters.size(); } + public Method getJavaMethod() { return javaMethod; } + /** @deprecated Use getTypeOffset() or getNameOffset() instead */ + @Deprecated + public int getDeclarationOffset() { return typeOffset; } + public int getFullDeclarationOffset() { return fullDeclarationOffset; } + public int getTypeOffset() { return typeOffset; } + public int getNameOffset() { return nameOffset; } + public int getBodyStart() { return bodyStart; } + public int getBodyEnd() { return bodyEnd; } + public boolean isResolved() { return resolved; } + public boolean isDeclaration() { return isDeclaration; } + public boolean isCall() { return !isDeclaration; } + public int getModifiers() { return modifiers; } + public boolean isStatic() { return Modifier.isStatic(modifiers); } + public boolean isFinal() { return Modifier.isFinal(modifiers); } + public boolean isAbstract() { return Modifier.isAbstract(modifiers); } + public boolean isSynchronized() { return Modifier.isSynchronized(modifiers); } + public boolean isNative() { return Modifier.isNative(modifiers); } + public boolean isPublic() { return Modifier.isPublic(modifiers); } + public boolean isPrivate() { return Modifier.isPrivate(modifiers); } + public boolean isProtected() { return Modifier.isProtected(modifiers); } + public String getDocumentation() { return documentation; } + public JSDocInfo getJSDocInfo() { return jsDocInfo; } + public void setJSDocInfo(JSDocInfo jsDocInfo) { this.jsDocInfo = jsDocInfo; } + + // ==================== OVERRIDE/IMPLEMENTS GETTERS/SETTERS ==================== + + /** + * Check if this method overrides a parent class method. + */ + public boolean isOverride() { return overridesFrom != null; } + + /** + * Get the type containing the method this overrides. + * @return The parent class TypeInfo, or null if not an override + */ + public TypeInfo getOverridesFrom() { return overridesFrom; } + + /** + * Mark this method as overriding a parent class method. + * @param parentType The type containing the overridden method + */ + public void setOverridesFrom(TypeInfo parentType) { this.overridesFrom = parentType; } + + /** + * Check if this method implements an interface method. + */ + public boolean isImplements() { return implementsFrom != null; } + + /** + * Get the interface containing the method this implements. + * @return The interface TypeInfo, or null if not implementing + */ + public TypeInfo getImplementsFrom() { return implementsFrom; } + + /** + * Mark this method as implementing an interface method. + * @param interfaceType The interface containing the implemented method + */ + public void setImplementsFrom(TypeInfo interfaceType) { this.implementsFrom = interfaceType; } + + /** + * Check if this method either overrides or implements something. + */ + public boolean hasInheritanceMarker() { return isOverride() || isImplements(); } + + /** + * Check if a position is inside this method's body. + */ + public boolean containsPosition(int position) { + return position >= bodyStart && position < bodyEnd; + } + + /** + * Get the end of the method declaration (closing paren position). + * This is used for error highlighting of duplicate methods. + */ + public int getDeclarationEnd() { + // The declaration ends just before the opening brace + // We find the closing paren by searching backwards from bodyStart + return bodyStart > 0 ? bodyStart - 1 : nameOffset + name.length(); + } + + /** + * Check if this method has a parameter with the given name. + */ + public boolean hasParameter(String paramName) { + for (FieldInfo p : parameters) { + if (p.getName().equals(paramName)) { + return true; + } + } + return false; + } + + /** + * Get parameter info by name. + */ + public FieldInfo getParameter(String paramName) { + for (FieldInfo p : parameters) { + if (p.getName().equals(paramName)) { + return p; + } + } + return null; + } + + /** + * Get method signature for duplicate detection. + * Signature = name + parameter types (ignoring parameter names). + */ + public MethodSignature cachedSignature; + + public MethodSignature getSignature() { + if (cachedSignature == null) { + List paramTypes = new ArrayList<>(); + for (FieldInfo param : parameters) + paramTypes.add(param.getTypeInfo()); + cachedSignature = new MethodSignature(name, paramTypes); + } + return cachedSignature; + } + + /** + * Get the appropriate TokenType for highlighting this method. + */ + public TokenType getTokenType() { + if (isDeclaration) { + return TokenType.METHOD_DECL; + } + return resolved ? TokenType.METHOD_CALL : TokenType.DEFAULT; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("MethodInfo{"); + sb.append(name).append("("); + for (int i = 0; i < parameters.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(parameters.get(i).getName()); + } + sb.append(")"); + if (returnType != null) { + sb.append(" -> ").append(returnType.getSimpleName()); + } + sb.append(", ").append(isDeclaration ? "decl" : "call"); + sb.append(", ").append(resolved ? "resolved" : "unresolved"); + sb.append("}"); + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MethodInfo that = (MethodInfo) o; + return name.equals(that.name) && parameters.size() == that.parameters.size(); + } + + @Override + public int hashCode() { + return name.hashCode() * 31 + parameters.size(); + } + + // ==================== ERROR HANDLING ==================== + + /** + * Represents an error with a specific parameter. + */ + public static class ParameterError { + private final FieldInfo parameter; + private final int paramIndex; + private final ErrorType errorType; + private final String message; + + public ParameterError(FieldInfo parameter, int paramIndex, ErrorType errorType, String message) { + this.parameter = parameter; + this.paramIndex = paramIndex; + this.errorType = errorType; + this.message = message; + } + + public FieldInfo getParameter() { return parameter; } + public int getParamIndex() { return paramIndex; } + public ErrorType getErrorType() { return errorType; } + public String getMessage() { return message; } + } + + /** + * Represents an error with a return statement (type mismatch). + */ + public static class ReturnStatementError { + private final int startOffset; // Start of "return" keyword (absolute position) + private final int endOffset; // End of return statement (after semicolon) + private final String message; + private final TypeInfo expectedType; + private final TypeInfo actualType; + + public ReturnStatementError(int startOffset, int endOffset, String message, TypeInfo expectedType, TypeInfo actualType) { + this.startOffset = startOffset; + this.endOffset = endOffset; + this.message = message; + this.expectedType = expectedType; + this.actualType = actualType; + } + + public int getStartOffset() { return startOffset; } + public int getEndOffset() { return endOffset; } + public String getMessage() { return message; } + public TypeInfo getExpectedType() { return expectedType; } + public TypeInfo getActualType() { return actualType; } + } + + /** + * Check if this method declaration has any errors. + */ + public boolean hasError() { + return errorType != ErrorType.NONE || !parameterErrors.isEmpty() || !returnStatementErrors.isEmpty(); + } + + /** + * Check if this has a missing return error. + */ + public boolean hasMissingReturnError() { + return errorType == ErrorType.MISSING_RETURN; + } + + /** + * Check if this has parameter errors. + */ + public boolean hasParameterErrors() { + return !parameterErrors.isEmpty(); + } + + /** + * Check if this has return statement type errors. + */ + public boolean hasReturnStatementErrors() { + return !returnStatementErrors.isEmpty(); + } + + public ErrorType getErrorType() { return errorType; } + public String getErrorMessage() { return errorMessage; } + public List getParameterErrors() { return Collections.unmodifiableList(parameterErrors); } + public List getReturnStatementErrors() { return Collections.unmodifiableList(returnStatementErrors); } + + /** + * Set the main error for this method. + */ + public void setError(ErrorType type, String message) { + this.errorType = type; + this.errorMessage = message; + } + + /** + * Add a parameter-specific error. + */ + public void addParameterError(FieldInfo param, int index, ErrorType type, String message) { + parameterErrors.add(new ParameterError(param, index, type, message)); + } + + public void addSamTypeError(int paramIndex, TypeInfo expectedSamType, TypeInfo actualDeclaredType) { + if (paramIndex < 0 || paramIndex >= parameters.size()) { + return; + } + FieldInfo param = parameters.get(paramIndex); + String message = "Explicit type '" + actualDeclaredType.getSimpleName() + + "' is incompatible with SAM parameter type '" + expectedSamType.getSimpleName() + "'"; + parameterErrors.add(new ParameterError(param, paramIndex, ErrorType.SAM_TYPE_INCOMPATIBLE, message)); + } + + /** + * Add a return statement error. + */ + public void addReturnStatementError(int startOffset, int endOffset, String message, TypeInfo expectedType, TypeInfo actualType) { + returnStatementErrors.add(new ReturnStatementError(startOffset, endOffset, message, expectedType, actualType)); + } + + /** + * Functional interface for type resolution callback. + */ + @FunctionalInterface + public interface TypeResolver { + TypeInfo resolveExpression(String expression, int position); + } + + + /** + * Validate this method declaration with type resolution for return statements. + * Checks for parameter errors, missing return statements, and return type mismatches. + * + * @param methodBodyText The text content of the method body (between { and }) + * @param typeResolver Optional callback to resolve expression types (for return type checking) + */ + public void validate(String methodBodyText, boolean hasBody, TypeResolver typeResolver) { + if (!isDeclaration) return; + + // Don't error if return type unresolved + if(returnType != null && !returnType.isResolved()) + return; + + // Validate parameters + validateParameters(); + boolean interfaceMember = containingType != null && containingType.isInterface(); + if (hasBody && (interfaceMember || isAbstract() || isNative())) { + setError(MethodInfo.ErrorType.INTERFACE_METHOD_BODY, "Interface or abstract methods cannot have a body"); + return; + } + + //No need to check for return types/statements + if(interfaceMember) + return; + + //If no body and not interface or abstract/native + if (bodyStart == bodyEnd && !isAbstract() && !isNative()) { + setError(ErrorType.MISSING_BODY, "Method must have a body or be declared abstract/native."); + return; + } + + // Validate return statement types FIRST if we have a type resolver + // This ensures type errors are shown even if there's also a missing return + if (typeResolver != null) { + validateReturnTypes(methodBodyText, typeResolver); + } + + // Validate return statement for non-void methods + // This may set the main error, but return type errors are already recorded + validateReturnStatement(methodBodyText); + } + + /** + * Check for duplicate and unresolved parameters. + */ + private void validateParameters() { + Set seenNames = new HashSet<>(); + + for (int i = 0; i < parameters.size(); i++) { + FieldInfo param = parameters.get(i); + String paramName = param.getName(); + + // Check for duplicate parameter names + if (seenNames.contains(paramName)) { + addParameterError(param, i, ErrorType.DUPLICATE_PARAMETER, + "Duplicate parameter name '" + paramName + "'"); + } else { + seenNames.add(paramName); + } + + // Check for unresolved parameter types + TypeInfo paramType = param.getTypeInfo(); + if (paramType == null || !paramType.isResolved()) { + String typeName = paramType != null ? paramType.getDisplayName() : "unknown"; + addParameterError(param, i, ErrorType.PARAMETER_UNDEFINED, + "Cannot resolve parameter type '" + typeName + "'"); + } + } + } + + /** + * Validate that a non-void method has a guaranteed return statement. + * Delegates to {@link ControlFlowAnalyzer} for control flow analysis. + */ + private void validateReturnStatement(String bodyText) { + if (bodyText == null || bodyText.isEmpty()) return; + + // void methods don't need return statements + if (TypeChecker.isVoidType(returnType)) return; + + // Abstract/native methods don't have bodies + if (isAbstract() || isNative()) return; + + // Check if the method has a guaranteed return using the control flow analyzer + if (!ControlFlowAnalyzer.hasGuaranteedReturn(bodyText)) { + setError(ErrorType.MISSING_RETURN, "Missing return statement"); + } + } + + /** + * Validate that all return statements have compatible types. + * Delegates to {@link CodeParser} for parsing and {@link TypeChecker} for type checks. + */ + private void validateReturnTypes(String bodyText, TypeResolver typeResolver) { + if (bodyText == null || bodyText.isEmpty()) return; + + boolean isVoid = TypeChecker.isVoidType(returnType); + String expectedTypeName = isVoid ? "void" : returnType.getDisplayName(); + + // Remove comments but keep strings (we need accurate positions) + String cleanBody = CodeParser.removeComments(bodyText); + + // Find all return statements + int pos = 0; + while (pos < cleanBody.length()) { + // Look for "return" keyword + int returnPos = CodeParser.findReturnKeyword(cleanBody, pos); + if (returnPos < 0) break; + + // Find the semicolon ending this return statement + int semiPos = CodeParser.findReturnSemicolon(cleanBody, returnPos + 6); + if (semiPos < 0) { + pos = returnPos + 6; + continue; + } + + // Extract the return expression + String returnExpr = cleanBody.substring(returnPos + 6, semiPos).trim(); + + // Calculate absolute position (bodyStart + 1 is after the opening brace) + int absoluteReturnStart = bodyStart + 1 + returnPos; + int absoluteSemiEnd = bodyStart + 1 + semiPos + 1; // +1 to include semicolon + + // Check void method returning a value + if (isVoid && !returnExpr.isEmpty()) { + String message = "Cannot return a value from a method with void result type"; + addReturnStatementError(absoluteReturnStart, absoluteSemiEnd, message, returnType, null); + } + // Check non-void method return type compatibility + else if (!isVoid && !returnExpr.isEmpty() && typeResolver != null) { + // Resolve the expression type + TypeInfo actualType = typeResolver.resolveExpression(returnExpr, absoluteReturnStart); + + // Check type compatibility + if (!TypeChecker.isTypeCompatible(returnType, actualType)) { + String actualTypeName = actualType != null ? actualType.getSimpleName() : "null"; + String message = "Incompatible types.\nRequired: " + expectedTypeName + "\nFound: " + actualTypeName; + addReturnStatementError(absoluteReturnStart, absoluteSemiEnd, message, returnType, actualType); + } + } + + pos = semiPos + 1; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodSignature.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodSignature.java new file mode 100644 index 000000000..5cde0ac63 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodSignature.java @@ -0,0 +1,95 @@ +package noppes.npcs.client.gui.util.script.interpreter.method; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a method signature for robust comparison. + * Compares method name and parameter types as structured data, not as strings. + */ +public final class MethodSignature { + private final String methodName; + private final List parameterTypes; + + public MethodSignature(String methodName, List parameterTypes) { + this.methodName = methodName; + this.parameterTypes = new ArrayList<>(parameterTypes); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof MethodSignature)) + return false; + + MethodSignature other = (MethodSignature) obj; + + // Compare method names + if (!this.methodName.equals(other.methodName)) + return false; + + // Compare parameter count + if (this.parameterTypes.size() != other.parameterTypes.size()) + return false; + + // Compare each parameter type + for (int i = 0; i < this.parameterTypes.size(); i++) { + TypeInfo thisType = this.parameterTypes.get(i); + TypeInfo otherType = other.parameterTypes.get(i); + + // Both null = equal + if (thisType == null && otherType == null) + continue; + + // One null, one not = not equal + if (thisType == null || otherType == null) + return false; + + // Compare full type names + if (!thisType.getFullName().equals(otherType.getFullName())) + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = methodName.hashCode(); + for (TypeInfo paramType : parameterTypes) { + result = 31 * result + (paramType != null ? paramType.getFullName().hashCode() : 0); + } + return result; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(methodName); + sb.append("("); + for (int i = 0; i < parameterTypes.size(); i++) { + if (i > 0) + sb.append(", "); + TypeInfo type = parameterTypes.get(i); + sb.append(type != null ? type.getFullName() : "?"); + } + sb.append(")"); + return sb.toString(); + } + + public static String asString(Method javaMethod) { + StringBuilder sig = new StringBuilder(javaMethod.getName()); + sig.append("("); + Class[] paramTypes = javaMethod.getParameterTypes(); + for (int i = 0; i < paramTypes.length; i++) { + if (i > 0) + sig.append(", "); + sig.append(paramTypes[i].getSimpleName()); + } + sig.append(")"); + return sig.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/package-info.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/package-info.java new file mode 100644 index 000000000..0fe302521 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/package-info.java @@ -0,0 +1,102 @@ +/** + *

Interpreter Package - Java Syntax Highlighting System

+ * + *

This package provides a clean, modular rewrite of the JavaTextContainer + * syntax highlighting system. It replaces the original implementation with + * well-structured, atomic classes that work together like LEGO pieces.

+ * + *

Architecture Overview

+ * + *
+ * ┌─────────────────────────────────────────────────────────────────┐
+ * │                    ScriptTextContainer                          │
+ * │  (Compatibility adapter extending JavaTextContainer)            │
+ * │  - USE_NEW_INTERPRETER flag to toggle between systems           │
+ * │  - Produces JavaTextContainer.LineData/Token for GUI compat     │
+ * └───────────────────────┬─────────────────────────────────────────┘
+ *                         │
+ *                         ▼
+ * ┌─────────────────────────────────────────────────────────────────┐
+ * │                     ScriptDocument                              │
+ * │  (Main document container - replaces JavaTextContainer core)    │
+ * │                                                                 │
+ * │  7-Phase Tokenization Pipeline:                                 │
+ * │  1. findExcludedRanges() - strings/comments                     │
+ * │  2. parseImports() - import statements                          │
+ * │  3. parseStructure() - methods, fields                          │
+ * │  4. buildMarks() - all highlighting marks                       │
+ * │  5. resolveConflicts() - priority-based resolution              │
+ * │  6. buildTokens() - convert marks to tokens per line            │
+ * │  7. computeIndentGuides() - visual indent guides                │
+ * └───────────────────────┬─────────────────────────────────────────┘
+ *                         │
+ *                         ▼
+ * ┌─────────────────────────────────────────────────────────────────┐
+ * │                      ScriptLine                                 │
+ * │  (Line container with token management and rendering)           │
+ * │  - Token list with navigation                                   │
+ * │  - Indent guides                                                │
+ * │  - drawString() with color codes                                │
+ * │  - drawStringHex() for direct hex color rendering               │
+ * └───────────────────────┬─────────────────────────────────────────┘
+ *                         │
+ *                         ▼
+ * ┌─────────────────────────────────────────────────────────────────┐
+ * │                        Token                                    │
+ * │  (Atomic token unit with type-specific metadata)                │
+ * │  - Text, start/end offsets                                      │
+ * │  - TokenType (with hex color and priority)                      │
+ * │  - prev()/next() navigation                                     │
+ * │  - Type-specific metadata: TypeInfo, FieldInfo, MethodInfo      │
+ * └─────────────────────────────────────────────────────────────────┘
+ * 
+ * + *

Supporting Classes

+ * + *
    + *
  • TokenType - Enum with hex colors and priorities for all token types
  • + *
  • TypeInfo - Immutable type metadata (class/interface/enum)
  • + *
  • TypeResolver - Class resolution with caching (replaces ClassPathFinder)
  • + *
  • ImportData - Import statement tracking with resolution status
  • + *
  • FieldInfo - Field/variable metadata with scope (global/local/parameter)
  • + *
  • MethodInfo - Method declaration/call metadata with parameters
  • + *
+ * + *

Key Improvements Over Original

+ * + *
    + *
  1. Single-pass tokenization - 7 distinct phases vs multiple loops
  2. + *
  3. Token navigation - prev()/next() methods for easy traversal
  4. + *
  5. Type-specific metadata - Rich information attached to tokens
  6. + *
  7. Hex color support - Direct RGB colors in addition to MC color codes
  8. + *
  9. Clean separation - Each class has one responsibility
  10. + *
  11. Immutable data - TypeInfo, FieldInfo, MethodInfo are immutable
  12. + *
  13. Feature toggle - USE_NEW_INTERPRETER flag for safe rollback
  14. + *
+ * + *

Usage

+ * + *
{@code
+ * // The new system is automatically used via ScriptTextContainer
+ * // To disable and use original JavaTextContainer:
+ * ScriptTextContainer.USE_NEW_INTERPRETER = false;
+ * 
+ * // To access the underlying ScriptDocument:
+ * ScriptTextContainer container = (ScriptTextContainer) textArea.getContainer();
+ * ScriptDocument doc = container.getDocument();
+ * 
+ * // Token navigation example:
+ * for (ScriptLine line : doc.getLines()) {
+ *     for (Token token : line.getTokens()) {
+ *         Token next = token.next();
+ *         Token prev = token.prev();
+ *     }
+ * }
+ * }
+ * + * @since 2.0 + * @see ScriptTextContainer + * @see ScriptDocument + * @see Token + */ +package noppes.npcs.client.gui.util.script.interpreter; diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java new file mode 100644 index 000000000..b7b81af9e --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java @@ -0,0 +1,271 @@ +package noppes.npcs.client.gui.util.script.interpreter.token; + +import noppes.npcs.client.gui.util.script.interpreter.type.ImportData; +import noppes.npcs.client.gui.util.script.interpreter.ScriptLine; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; + +/** + * Represents a single token in the source code with its type and metadata. + * Tokens are the atomic units of the syntax highlighting system. + * + * Each token knows: + * - Its text content + * - Its position in the global source + * - Its type (for coloring) + * - Type-specific metadata (resolved class info, declaration info, etc.) + */ +public class Token { + + private final String text; + private final int globalStart; // start offset in the full document + private final int globalEnd; // end offset in the full document + private TokenType type; + + // Optional metadata based on token type + private TypeInfo typeInfo; // For class references, type declarations + private FieldInfo fieldInfo; // For field references + private MethodInfo methodInfo; // For method calls/declarations + private MethodCallInfo methodCallInfo; // For method calls with argument info + private ImportData importData; // For import statements + private FieldAccessInfo fieldAccessInfo; //For fields like Minecraft.getMinecraft.thePlayer + private TokenErrorMessage errorMessage; + + + // Navigation - set by ScriptLine + private Token prev; + private Token next; + private ScriptLine parentLine; + + public Token(String text, int globalStart, int globalEnd, TokenType type) { + this.text = text; + this.globalStart = globalStart; + this.globalEnd = globalEnd; + this.type = type; + } + + // ==================== FACTORY METHODS ==================== + + public static Token defaultToken(String text, int start, int end) { + return new Token(text, start, end, TokenType.DEFAULT); + } + + public static Token keyword(String text, int start, int end) { + return new Token(text, start, end, TokenType.KEYWORD); + } + + public static Token modifier(String text, int start, int end) { + return new Token(text, start, end, TokenType.MODIFIER); + } + + public static Token comment(String text, int start, int end) { + return new Token(text, start, end, TokenType.COMMENT); + } + + public static Token string(String text, int start, int end) { + return new Token(text, start, end, TokenType.STRING); + } + + public static Token literal(String text, int start, int end) { + return new Token(text, start, end, TokenType.LITERAL); + } + + public static Token typeReference(String text, int start, int end, TypeInfo info) { + Token t = new Token(text, start, end, info != null ? info.getTokenType() : TokenType.TYPE_DECL); + t.typeInfo = info; + return t; + } + + public static Token methodDecl(String text, int start, int end, MethodInfo info) { + Token t = new Token(text, start, end, TokenType.METHOD_DECL); + t.methodInfo = info; + return t; + } + + public static Token methodCall(String text, int start, int end, MethodInfo info, boolean resolved) { + Token t = new Token(text, start, end, resolved ? TokenType.METHOD_CALL : TokenType.DEFAULT); + t.methodInfo = info; + return t; + } + + public static Token globalField(String text, int start, int end, FieldInfo info) { + Token t = new Token(text, start, end, TokenType.GLOBAL_FIELD); + t.fieldInfo = info; + return t; + } + + public static Token localField(String text, int start, int end, FieldInfo info) { + Token t = new Token(text, start, end, TokenType.LOCAL_FIELD); + t.fieldInfo = info; + return t; + } + + public static Token parameter(String text, int start, int end, FieldInfo info) { + Token t = new Token(text, start, end, TokenType.PARAMETER); + t.fieldInfo = info; + return t; + } + + public static Token undefined(String text, int start, int end) { + return new Token(text, start, end, TokenType.UNDEFINED_VAR); + } + + // ==================== GETTERS ==================== + + public String getText() { return text; } + public int getGlobalStart() { return globalStart; } + public int getGlobalEnd() { return globalEnd; } + public int getLength() { return globalEnd - globalStart; } + public TokenType getType() { return type; } + public TypeInfo getTypeInfo() { return typeInfo; } + public FieldInfo getFieldInfo() { return fieldInfo; } + public MethodInfo getMethodInfo() { return methodInfo; } + public MethodCallInfo getMethodCallInfo() { return methodCallInfo; } + public FieldAccessInfo getFieldAccessInfo() { return fieldAccessInfo; } + public ImportData getImportData() { return importData; } + public TokenErrorMessage getErrorMessage() { return errorMessage; } + public ScriptLine getParentLine() { return parentLine; } + + // ==================== SETTERS ==================== + + public void setType(TokenType type) { this.type = type; } + public void setTypeInfo(TypeInfo info) { this.typeInfo = info; } + public void setFieldInfo(FieldInfo info) { this.fieldInfo = info; } + public void setFieldAccessInfo(FieldAccessInfo info) { this.fieldAccessInfo = info; } + public void setMethodInfo(MethodInfo info) { this.methodInfo = info; } + public void setMethodCallInfo(MethodCallInfo info) { this.methodCallInfo = info; } + public void setImportData(ImportData data) { this.importData = data; } + public void setErrorMessage(TokenErrorMessage message) { this.errorMessage = message; } + + public void setParentLine(ScriptLine line) { this.parentLine = line; } + public void setPrev(Token prev) { this.prev = prev; } + public void setNext(Token next) { this.next = next; } + + // ==================== NAVIGATION ==================== + + /** + * Get the previous token (may be on a previous line). + */ + public Token prev() { + if (prev != null) return prev; + if (parentLine == null) return null; + + ScriptLine prevLine = parentLine.prev(); + while (prevLine != null) { + Token last = prevLine.getLastToken(); + if (last != null) return last; + prevLine = prevLine.prev(); + } + return null; + } + + /** + * Get the next token (may be on a following line). + */ + public Token next() { + if (next != null) return next; + if (parentLine == null) return null; + + ScriptLine nextLine = parentLine.next(); + while (nextLine != null) { + Token first = nextLine.getFirstToken(); + if (first != null) return first; + nextLine = nextLine.next(); + } + return null; + } + + /** + * Get the previous token on the same line only. + */ + public Token prevOnLine() { + return prev; + } + + /** + * Get the next token on the same line only. + */ + public Token nextOnLine() { + return next; + } + + // ==================== TYPE CHECKS ==================== + + public boolean isKeyword() { + return type == TokenType.KEYWORD || type == TokenType.MODIFIER || + type == TokenType.CLASS_KEYWORD || type == TokenType.IMPORT_KEYWORD; + } + + public boolean isTypeReference() { + return type == TokenType.TYPE_DECL || type == TokenType.IMPORTED_CLASS || + type == TokenType.CLASS_DECL || type == TokenType.INTERFACE_DECL || + type == TokenType.ENUM_DECL; + } + + public boolean isEnumConstant() { + return type == TokenType.ENUM_CONSTANT; + } + public boolean isResolved() { + if (typeInfo != null) return typeInfo.isResolved(); + if (fieldInfo != null) return fieldInfo.isResolved(); + if (methodInfo != null) return methodInfo.isResolved(); + return type != TokenType.UNDEFINED_VAR; + } + + public boolean isIdentifier() { + if (text.isEmpty()) return false; + char first = text.charAt(0); + return Character.isJavaIdentifierStart(first); + } + + public boolean startsWithUpperCase() { + return !text.isEmpty() && Character.isUpperCase(text.charAt(0)); + } + + public boolean isMethodCall() { + return type == TokenType.METHOD_CALL || + (type == TokenType.DEFAULT && methodInfo != null); + } + + public boolean isField() { + return type == TokenType.GLOBAL_FIELD || type == TokenType.LOCAL_FIELD || + type == TokenType.PARAMETER || type == TokenType.STATIC_FINAL_FIELD || + type == TokenType.ENUM_CONSTANT; + } + + // ==================== RENDERING ==================== + + /** + * Get the hex color for this token. + */ + public int getHexColor() { + return type.getHexColor(); + } + + /** + * Get the Minecraft color code character. + */ + public char getColorCode() { + return type.toColorCode(); + } + + /** + * Get the Minecraft style prefix for this token (bold/italic codes). + * Returns empty string if no style, otherwise §l for bold, §o for italic, or both. + */ + public String getStylePrefix() { + StringBuilder sb = new StringBuilder(); + if (type.isBold()) sb.append('\u00A7').append('l'); + if (type.isItalic()) sb.append('\u00A7').append('o'); + return sb.toString(); + } + + @Override + public String toString() { + return "Token{'" + text + "', " + type + ", [" + globalStart + "-" + globalEnd + "]}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenErrorMessage.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenErrorMessage.java new file mode 100644 index 000000000..7b5115eb2 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenErrorMessage.java @@ -0,0 +1,23 @@ +package noppes.npcs.client.gui.util.script.interpreter.token; + +public class TokenErrorMessage { + private final String message; + public boolean clearOtherErrors; + + public TokenErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public static TokenErrorMessage from(String message) { + return new TokenErrorMessage(message); + } + + public TokenErrorMessage clearOtherErrors() { + this.clearOtherErrors = true; + return this; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java new file mode 100644 index 000000000..2cb57da62 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java @@ -0,0 +1,150 @@ +package noppes.npcs.client.gui.util.script.interpreter.token; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +/** + * Defines all token types for syntax highlighting with hex colors and priorities. + * Priority determines which token type wins when marks overlap. + * Higher priority = wins conflicts. + */ +public enum TokenType { + // Comments and strings have highest priority - they override everything inside them + COMMENT(0x777777, 140), + STRING(0xCC8855, 130), + + // JSDoc elements - lower priority but will fill gaps left by fragmented comment marking + JSDOC_TAG(0xCC9933, 125), // @param, @type, @return etc. (gold/orange) + JSDOC_TYPE(0x00AAAA, 124), // {TypeName} in JSDoc (aqua like types) + + UNUSED_IMPORT(0x666666, 119), // unused import statements (gray) + + // Keywords and modifiers + CLASS_KEYWORD(0xFF5555, 115), // 'class', 'interface', 'enum' keywords + IMPORT_KEYWORD(0xFFAA00, 110), // 'import' keyword + KEYWORD(0xFF5555, 100), // control flow: if, else, for, while, etc. + MODIFIER(0xFFAA00, 90), // public, private, static, final, etc. + + // Type declarations and references + INTERFACE_DECL(0x55FFFF, 85), // interface names (aqua) + ENUM_DECL(0xFF55FF, 85), // enum names (magenta) + ENUM_CONSTANT(0x55FFFF, 84, true, false), // enum constant values (blue, bold+italic) - like IntelliJ + CLASS_DECL(0x00AAAA, 85), // class names in declarations + IMPORTED_CLASS(0x00AAAA, 75), // imported class usages + TYPE_DECL(0x00AAAA, 70), // package paths, type references + + // Methods + METHOD_DECL(0x00AA00, 60), // method declarations (green) + METHOD_CALL(0x55FF55, 50), // method calls (bright green) + + // Variables and fields + UNDEFINED_VAR(0xAA0000, 20), // unresolved variables (dark red) - high priority + PARAMETER(0x5555FF, 36), // method parameters (blue) + GLOBAL_FIELD(0x55FFFF, 35), // class-level fields (aqua) + LOCAL_FIELD(0xFFFF55, 25), // local variables (yellow) + STATIC_FINAL_FIELD(0xFF55FF, 36, false, true), // static final fields (magenta, italic) + + // Literals + LITERAL(0x777777, 40), // numeric and boolean literals + + // Default + VARIABLE(0xFFFFFF, 30), // generic variables + DEFAULT(0xFFFFFF, 0); // default text color (white) + + private final int hexColor; + private final int priority; + private final boolean bold; + private final boolean italic; + + TokenType(int hexColor, int priority) { + this(hexColor, priority, false, false); + } + + TokenType(int hexColor, int priority, boolean bold, boolean italic) { + this.hexColor = hexColor; + this.priority = priority; + this.bold = bold; + this.italic = italic; + } + + public int getHexColor() { + return hexColor; + } + + public int getPriority() { + return priority; + } + + public boolean isBold() { + return bold; + } + + public boolean isItalic() { + return italic; + } + + public static TokenType getByType(TypeInfo typeInfo) { + if (typeInfo == null || !typeInfo.isResolved()) + return TokenType.UNDEFINED_VAR; + + if ("any".equals(typeInfo.getFullName())) + return TokenType.KEYWORD; + + // Use the TypeInfo's own token type, which handles ScriptTypeInfo correctly + return typeInfo.getTokenType(); + } + + public static int getColor(TypeInfo typeInfo) { + return getByType(typeInfo).getHexColor(); + } + + /** + * Convert this token type to a Minecraft color code character. + * Used for backward compatibility with the existing rendering system. + */ + public char toColorCode() { + switch (this) { + case COMMENT: + case LITERAL: + case UNUSED_IMPORT: + return '7'; // gray + case STRING: + return '5'; // purple + case JSDOC_TAG: + return '6'; // gold + case JSDOC_TYPE: + return '3'; // dark aqua (like types) + case CLASS_KEYWORD: + case KEYWORD: + return 'c'; // red + case IMPORT_KEYWORD: + case MODIFIER: + return '6'; // gold + case ENUM_DECL: + case STATIC_FINAL_FIELD: + return 'd'; // magenta + case ENUM_CONSTANT: + return '9'; // blue (same as PARAMETER) + case INTERFACE_DECL: + case GLOBAL_FIELD: + return 'b'; // aqua + case CLASS_DECL: + case IMPORTED_CLASS: + case TYPE_DECL: + return '3'; // dark aqua + case METHOD_DECL: + return '2'; // dark green + case METHOD_CALL: + return 'a'; // green + case LOCAL_FIELD: + return 'e'; // yellow + case PARAMETER: + return '9'; // blue + case UNDEFINED_VAR: + return '4'; // dark red + case VARIABLE: + case DEFAULT: + default: + return 'f'; // white + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/CLASSINDEX_USAGE.txt b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/CLASSINDEX_USAGE.txt new file mode 100644 index 000000000..2adbda50f --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/CLASSINDEX_USAGE.txt @@ -0,0 +1,20 @@ +/** + * Example Usage of ClassIndex: + * + * 1. Add a single class: + * ClassIndex.getInstance().addClass(EntityPlayer.class); + * + * 2. Add an entire package recursively: + * ClassIndex.getInstance().addPackage("net.minecraft.entity.player"); + * This will scan and add ALL classes in that package and subpackages. + * + * 3. Find classes by prefix: + * List matches = ClassIndex.getInstance().findByPrefix("Entity", 50); + * // Returns fully-qualified names like "net.minecraft.entity.Entity", etc. + * + * Benefits: + * - No more manual string pairs! Just pass Class objects. + * - Simple name and package are extracted automatically via reflection. + * - Can scan entire package trees recursively with one call. + * - Cleaner, more maintainable, and easier to extend. + */ diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ClassIndex.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ClassIndex.java new file mode 100644 index 000000000..694dd40d1 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ClassIndex.java @@ -0,0 +1,326 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import java.io.File; +import java.net.URL; +import java.util.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Index of Java classes for autocomplete suggestions. + * Uses reflection to automatically extract class names and packages. + * Supports multiple classes with the same simple name (e.g., java.util.Date and java.sql.Date). + */ +public class ClassIndex { + private static ClassIndex instance; + + // Map simple names to multiple classes (handles name collisions) + private final Map>> simpleNameToClasses = new LinkedHashMap<>(); + private final Set> registeredClasses = new HashSet<>(); + + private ClassIndex() { + // Initialize with common classes + initializeCommonClasses(); + } + + public static ClassIndex getInstance() { + if (instance == null) { + instance = new ClassIndex(); + } + return instance; + } + + public static void init(){ + getInstance(); + } + /** + * Register a single class by its Class object. + * The simple name and full name are extracted via reflection. + * Supports multiple classes with the same simple name. + */ + public void addClass(Class clazz) { + if (clazz == null) return; + + // Check if already registered using the Class object itself + if (registeredClasses.contains(clazz)) { + return; // Already registered + } + + registeredClasses.add(clazz); + String simpleName = clazz.getSimpleName(); + + // Only add if simple name is not empty (avoid inner classes with $ in name) + if (!simpleName.isEmpty() && !simpleName.contains("$")) { + // Add to list of classes with this simple name + simpleNameToClasses.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(clazz); + } + } + + /** + * Register all classes in a package and all subpackages recursively. + * Uses ClassLoader to scan the classpath. + */ + public void addPackage(String packageName) { + addPackage(packageName, new String[0]); + } + + /** + * Register all classes in a package and all subpackages recursively, + * excluding specified packages. + * Uses ClassLoader to scan the classpath. + * + * @param packageName The base package to scan + * @param excludedPackages Array of package names to exclude (including their subpackages) + */ + public void addPackage(String packageName, String... excludedPackages) { + try { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + String path = packageName.replace('.', '/'); + Enumeration resources = classLoader.getResources(path); + + Set classNames = new HashSet<>(); + Set excludedPrefixes = new HashSet<>(); + + // Prepare excluded package prefixes for matching + for (String excluded : excludedPackages) { + if (excluded != null && !excluded.isEmpty()) { + excludedPrefixes.add(excluded + "."); + } + } + + while (resources.hasMoreElements()) { + URL resource = resources.nextElement(); + + if (resource.getProtocol().equals("file")) { + // Scan directory + File directory = new File(resource.getFile()); + scanDirectory(directory, packageName, classNames, excludedPrefixes); + } else if (resource.getProtocol().equals("jar")) { + // Scan JAR file + String jarPath = resource.getPath(); + if (jarPath.startsWith("file:")) { + jarPath = jarPath.substring(5); + } + int separatorIndex = jarPath.indexOf("!"); + if (separatorIndex != -1) { + jarPath = jarPath.substring(0, separatorIndex); + } + scanJar(jarPath, packageName, classNames, excludedPrefixes); + } + } + + // Load all discovered classes + for (String className : classNames) { + try { + Class clazz = Class.forName(className, false, classLoader); + addClass(clazz); + } catch (ClassNotFoundException | NoClassDefFoundError | ExceptionInInitializerError e) { + // Skip classes that can't be loaded + } + } + } catch (Exception e) { + // Silently fail - package might not exist + } + } + + private void scanDirectory(File directory, String packageName, Set classNames) { + scanDirectory(directory, packageName, classNames, new HashSet<>()); + } + + private void scanDirectory(File directory, String packageName, Set classNames, Set excludedPrefixes) { + if (!directory.exists() || !directory.isDirectory()) { + return; + } + + // Check if this package is excluded + for (String excludedPrefix : excludedPrefixes) { + if ((packageName + ".").startsWith(excludedPrefix) || packageName.equals(excludedPrefix.substring(0, excludedPrefix.length() - 1))) { + return; // Skip excluded package + } + } + + File[] files = directory.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + String fileName = file.getName(); + + if (file.isDirectory()) { + // Recursively scan subdirectory + scanDirectory(file, packageName + "." + fileName, classNames, excludedPrefixes); + } else if (fileName.endsWith(".class")) { + // Add class name + String className = packageName + "." + fileName.substring(0, fileName.length() - 6); + classNames.add(className); + } + } + } + + private void scanJar(String jarPath, String packageName, Set classNames) { + scanJar(jarPath, packageName, classNames, new HashSet<>()); + } + + private void scanJar(String jarPath, String packageName, Set classNames, Set excludedPrefixes) { + try { + JarFile jarFile = new JarFile(jarPath); + Enumeration entries = jarFile.entries(); + String packagePath = packageName.replace('.', '/'); + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String entryName = entry.getName(); + + if (entryName.startsWith(packagePath) && entryName.endsWith(".class")) { + // Convert path to class name + String className = entryName + .substring(0, entryName.length() - 6) + .replace('/', '.'); + + // Check if this class is in an excluded package + boolean excluded = false; + for (String excludedPrefix : excludedPrefixes) { + if ((className + ".").startsWith(excludedPrefix) || + className.startsWith(excludedPrefix.substring(0, excludedPrefix.length() - 1) + ".")) { + excluded = true; + break; + } + } + + if (!excluded) { + classNames.add(className); + } + } + } + + jarFile.close(); + } catch (Exception e) { + // Silently fail + } + } + + /** + * Find all classes whose simple name starts with the given prefix (case-insensitive). + * Returns fully-qualified class names for all matches, including multiple classes with the same simple name. + */ + public List findByPrefix(String prefix, int maxResults) { + List results = new ArrayList<>(); + String lowerPrefix = prefix.toLowerCase(); + + for (Map.Entry>> entry : simpleNameToClasses.entrySet()) { + if (entry.getKey().toLowerCase().startsWith(lowerPrefix)) { + // Add all classes with this simple name + for (Class clazz : entry.getValue()) { + results.add(clazz.getName()); + + if (maxResults != -1 && results.size() >= maxResults) { + return results; + } + } + } + } + + return results; + } + + /** + * Initialize the index with commonly used Java, Minecraft, and NPC classes. + */ + private void initializeCommonClasses() { + // Java standard library + addClass(String.class); + addClass(Integer.class); + addClass(Double.class); + addClass(Float.class); + addClass(Long.class); + addClass(Boolean.class); + addClass(Character.class); + addClass(Byte.class); + addClass(Short.class); + addClass(Object.class); + addClass(Math.class); + addClass(System.class); + addClass(Thread.class); + addClass(Runnable.class); + addClass(Exception.class); + addClass(RuntimeException.class); + addClass(Error.class); + addClass(Throwable.class); + + // Java collections + addClass(List.class); + addClass(ArrayList.class); + addClass(LinkedList.class); + addClass(Set.class); + addClass(HashSet.class); + addClass(LinkedHashSet.class); + addClass(TreeSet.class); + addClass(Map.class); + addClass(HashMap.class); + addClass(LinkedHashMap.class); + addClass(TreeMap.class); + addClass(Collection.class); + addClass(Iterator.class); + addClass(Comparator.class); + addClass(Collections.class); + addClass(Arrays.class); + addClass(Queue.class); + addClass(Deque.class); + addClass(Stack.class); + addClass(Vector.class); + addClass(Hashtable.class); + + // Java I/O + addClass(java.io.File.class); + addClass(java.io.InputStream.class); + addClass(java.io.OutputStream.class); + addClass(java.io.Reader.class); + addClass(java.io.Writer.class); + addClass(java.io.BufferedReader.class); + addClass(java.io.BufferedWriter.class); + addClass(java.io.FileReader.class); + addClass(java.io.FileWriter.class); + addClass(java.io.IOException.class); + + // Java utilities + addClass(java.util.Random.class); + addClass(java.util.Date.class); + addClass(java.util.Calendar.class); + addClass(java.util.UUID.class); + addClass(java.util.regex.Pattern.class); + addClass(java.util.regex.Matcher.class); + addPackage("java.util.function"); + + // Now add common Minecraft/Forge/NPC packages + addPackage("net.minecraft.entity"); + addPackage("net.minecraft.item"); + addPackage("net.minecraft.block"); + addPackage("net.minecraft.world"); + addPackage("net.minecraft.util"); + addPackage("net.minecraft.nbt"); + addPackage("net.minecraft.potion"); + addPackage("net.minecraft.enchantment"); + addPackage("net.minecraft.inventory"); + addPackage("net.minecraft.tileentity"); + addPackage("net.minecraft.command"); + addPackage("net.minecraft.client"); + + // Forge + addPackage("net.minecraftforge.common"); + addPackage("net.minecraftforge.event"); + addPackage("net.minecraftforge.fml.common"); + + // CustomNPCs + addPackage("noppes.npcs"); + + // DBC (if available) + try { + addPackage("JinRyuu.JRMCore"); + addPackage("JinRyuu.DragonBC"); + addPackage("kamkeel.npcdbc"); + } catch (Exception e) { + // DBC might not be available + } + } +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ClassTypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ClassTypeInfo.java new file mode 100644 index 000000000..c19faeb2c --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ClassTypeInfo.java @@ -0,0 +1,73 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +/** + * Represents a reference to a Java Class itself (not an instance). + * Used for patterns like: var File = Java.type("java.io.File"); + * + * When you have a ClassTypeInfo: + * - Accessing members should show ONLY static members (File.listRoots()) + * - Using 'new' should return an instance of the wrapped class (new File("path")) + * + * This is distinct from a regular TypeInfo which represents an instance of that type. + */ +public class ClassTypeInfo extends TypeInfo { + + private final TypeInfo instanceType; // The type you get when you do 'new' on this class + + /** + * Create a ClassTypeInfo wrapping a Java class. + * + * @param javaClass The Java class this represents (e.g., java.io.File.class) + */ + public ClassTypeInfo(Class javaClass) { + super( + javaClass.getSimpleName(), + javaClass.getName(), + javaClass.getPackage() != null ? javaClass.getPackage().getName() : "", + javaClass.isInterface() ? Kind.INTERFACE : (javaClass.isEnum() ? Kind.ENUM : Kind.CLASS), + javaClass, + true, + null, + true // subclass marker + ); + this.instanceType = TypeInfo.fromClass(javaClass); + } + + /** + * Create a ClassTypeInfo from an existing TypeInfo. + */ + public ClassTypeInfo(TypeInfo instanceType) { + super( + instanceType.getSimpleName(), + instanceType.getFullName(), + instanceType.getPackageName(), + instanceType.getKind(), + instanceType.getJavaClass(), + instanceType.isResolved(), + instanceType.getEnclosingType(), + true // subclass marker + ); + this.instanceType = instanceType; + } + + /** + * Returns true - this TypeInfo represents a class reference, not an instance. + */ + @Override + public boolean isClassReference() { + return true; + } + + /** + * Get the TypeInfo for instances of this class. + * This is what you get when you call 'new' on this class. + */ + public TypeInfo getInstanceType() { + return instanceType; + } + + @Override + public String toString() { + return "Class<" + getSimpleName() + ">"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java new file mode 100644 index 000000000..1d1162ac9 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java @@ -0,0 +1,269 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.interpreter.js_parser.TypeParamInfo; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A context for resolving and substituting declared type parameters (T, E, K, V...) + * based on a receiver type. + * + * Policy: + * - Prefer applied type arguments when present (e.g. List binds E -> String). + * - Fall back to declared bounds when no applied argument exists (e.g. T extends Entity -> Entity). + */ +public final class GenericContext { + + /** Singleton context for types with no generic parameters. Used to avoid object allocation. */ + private static final GenericContext EMPTY = new GenericContext(new HashMap<>(), new HashMap<>()); + + /** + * Map of type variable names to their applied type arguments. + * For example, in List<String>, this contains E -> TypeInfo.STRING. + * Applied bindings take precedence over bound fallbacks. + */ + private final Map appliedBindings; + + /** + * Map of type variable names to their declared upper bounds. + * For example, in <T extends Entity>, this contains T -> TypeInfo(Entity). + * Used as fallback when no applied argument exists. + */ + private final Map boundFallbacks; + + private GenericContext(Map appliedBindings, Map boundFallbacks) { + this.appliedBindings = appliedBindings != null ? appliedBindings : new HashMap<>(); + this.boundFallbacks = boundFallbacks != null ? boundFallbacks : new HashMap<>(); + } + + /** + * Create a GenericContext for a receiver type. + * + * Fast path: returns singleton EMPTY for non-generic types (no allocation overhead). + * Slow path: builds maps from type parameters and applied arguments for generic types. + * + * @param receiverType the type to extract generic bindings from (e.g., List<String>, DAO<T extends Entity>) + * @return a context with applied bindings and bound fallbacks, or EMPTY singleton if receiverType is not generic + */ + public static GenericContext forReceiver(TypeInfo receiverType) { + // Fast path: return singleton for non-generic types + if (receiverType == null || !hasGenerics(receiverType)) { + return EMPTY; + } + + TypeInfo rawType = receiverType.getRawType(); + List declaredParams = rawType != null ? rawType.getTypeParams() : null; + List appliedArgs = receiverType.getAppliedTypeArgs(); + + Map applied = new HashMap<>(); + if (declaredParams != null && appliedArgs != null) { + int count = Math.min(declaredParams.size(), appliedArgs.size()); + for (int i = 0; i < count; i++) { + TypeParamInfo declared = declaredParams.get(i); + TypeInfo arg = appliedArgs.get(i); + if (declared == null || declared.getName() == null || declared.getName().isEmpty() || arg == null) { + continue; + } + applied.put(declared.getName(), arg); + } + } + + Map bounds = new HashMap<>(); + if (declaredParams != null) { + for (TypeParamInfo declared : declaredParams) { + if (declared == null) continue; + String name = declared.getName(); + if (name == null || name.isEmpty()) continue; + TypeInfo bound = declared.getBoundTypeInfo(); + if (bound != null && bound.isResolved()) { + bounds.put(name, bound); + } + } + } + + return new GenericContext(applied, bounds); + } + + /** + * Check if a type has generics that need substitution. + * + * Returns true if: + * - Type is parameterized (e.g., List<String>) + * - Type's raw form differs from the type itself + * - Type has declared type parameters + * + * @param type the type to check + * @return true if this type contains generic information that may need substitution + */ + public static boolean hasGenerics(TypeInfo type) { + return type.isParameterized() || + type.getRawType() != type || + (type.getTypeParams() != null && !type.getTypeParams().isEmpty()); + } + + /** + * Resolve a type variable to its bound or applied type. + * + * Prefers applied bindings (e.g., E -> String in List<String>) over declared bounds. + * Falls back to bounds (e.g., T -> Entity in <T extends Entity>) if no applied argument exists. + * Returns null if the variable is not found in either map. + * + * @param name the type variable name (e.g., "T", "E", "K") + * @return the resolved TypeInfo, or null if variable not found + */ + public TypeInfo resolveTypeVariable(String name) { + if (name == null || name.isEmpty()) { + return null; + } + + TypeInfo applied = appliedBindings.get(name); + if (applied != null) { + return applied; + } + + return boundFallbacks.get(name); + } + + /** + * Substitute type variables in a TypeInfo recursively. + * + * Handles: + * - Direct type variables (unresolved types representing T, E, etc.) + * - Parameterized types (recursively substitutes inside type arguments) + * - Array types (substitutes the element type, preserves array dimensions) + * + * @param type the type to substitute (may contain type variables) + * @return the substituted type, or the original type if no substitutions apply + */ + public TypeInfo substitute(TypeInfo type) { + if (type == null) { + return null; + } + + // Direct type variable substitution (unresolved types represent type variables like T). + if (!type.isResolved()) { + TypeInfo substitution = resolveTypeVariable(type.getSimpleName()); + if (substitution != null) { + return substitution; + } + } + + // Parameterized types: substitute inside args. + if (type.isParameterized()) { + List originalArgs = type.getAppliedTypeArgs(); + List substitutedArgs = new ArrayList<>(originalArgs.size()); + boolean changed = false; + + for (TypeInfo arg : originalArgs) { + TypeInfo substitutedArg = substitute(arg); + substitutedArgs.add(substitutedArg); + if (substitutedArg != arg) { + changed = true; + } + } + + if (changed) { + return type.getRawType().parameterize(substitutedArgs); + } + } + + // Array wrapper types are represented by a display-name suffix; we can still substitute + // a plain "T[]" by looking up "T". + String simple = type.getSimpleName(); + if (simple != null && simple.endsWith("[]")) { + TypeStringNormalizer.ArraySplit split = TypeStringNormalizer.splitArraySuffixes(simple); + String elementName = split.base; + int dims = split.dimensions; + TypeInfo elementSub = resolveTypeVariable(elementName); + if (elementSub != null) { + TypeInfo result = elementSub; + for (int i = 0; i < dims; i++) { + result = TypeInfo.arrayOf(result); + } + return result; + } + } + + return type; + } + + /** + * Substitute type variables in a type string. + * + * Attempts substitution in this order: + * 1. Normalize the string (strip imports, pick union branch, remove nullable suffix, split arrays) + * 2. Try direct type variable lookup (e.g., "T" -> resolved Entity) + * 3. Fall back to full type resolution with substitution applied + * + * @param typeString the type expression as a string (e.g., "T", "T[]", "List<T>") + * @param resolver optional TypeResolver for fallback resolution; if null, returns null on failure + * @return the substituted type, or null if resolution fails + */ + public TypeInfo substituteString(String typeString, TypeResolver resolver) { + if (typeString == null || typeString.trim().isEmpty()) { + return null; + } + + String normalized = TypeStringNormalizer.stripImportTypeSyntax(typeString); + normalized = TypeStringNormalizer.pickPreferredUnionBranch(normalized); + normalized = TypeStringNormalizer.stripNullableSuffix(normalized); + + TypeStringNormalizer.ArraySplit arraySplit = TypeStringNormalizer.splitArraySuffixes(normalized); + String baseExpr = arraySplit.base; + int dims = arraySplit.dimensions; + + String bareBase = GenericTypeParser.stripGenerics(baseExpr); + TypeInfo direct = resolveTypeVariable(bareBase); + if (direct != null) { + TypeInfo result = direct; + for (int i = 0; i < dims; i++) { + result = TypeInfo.arrayOf(result); + } + return result; + } + + if (resolver == null) { + return null; + } + TypeInfo resolved = resolver.resolveJSType(typeString); + return substitute(resolved); + } + + /** + * Substitute type variables in a resolved type, with string fallback. + * + * Two-phase approach: + * 1. Try substituting the resolved type directly + * 2. If that fails, attempt substitution via the raw string as fallback + * + * This handles edge cases where the TypeInfo doesn't capture enough information + * to do proper substitution (e.g., union types, complex generics). + * + * @param resolvedType a TypeInfo that has already been resolved (may still contain type variables) + * @param rawTypeString the original type string before resolution (for fallback) + * @param resolver optional TypeResolver for string-based fallback resolution + * @return the substituted type, or the best attempt if full substitution fails + */ + public TypeInfo substituteType(TypeInfo resolvedType, String rawTypeString, TypeResolver resolver) { + if (resolvedType == null) { + return null; + } + + TypeInfo substituted = substitute(resolvedType); + if (substituted != null && substituted.isResolved()) { + return substituted; + } + + if (rawTypeString != null && !rawTypeString.trim().isEmpty()) { + TypeInfo fromString = substituteString(rawTypeString, resolver); + if (fromString != null && fromString.isResolved()) { + return fromString; + } + } + + return substituted; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericTypeParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericTypeParser.java new file mode 100644 index 000000000..727c85388 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericTypeParser.java @@ -0,0 +1,325 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parser for generic type expressions with nested type arguments. + * + * Handles type expressions like: + * - Simple types: "String", "IPlayer" + * - Qualified names: "java.util.List", "IPlayerEvent.InteractEvent" + * - Single generic: "List", "Consumer" + * - Multiple generics: "Map" + * - Nested generics: "List>" + * + * Note: union/nullability/array semantics are handled by higher-level resolvers. + * This parser only consumes suffix tokens where needed to correctly delimit type arguments. + * + * This parser produces a ParsedType tree that can be resolved into parameterized TypeInfo instances. + */ +public class GenericTypeParser { + + /** + * Represents a parsed type expression with potential generic arguments. + */ + public static class ParsedType { + /** The base type name (may be qualified, e.g., "java.util.List" or "IPlayer") */ + public final String baseName; + + /** The applied type arguments (for generics like List), each is itself a ParsedType */ + public final List typeArgs; + + /** The original raw string before parsing */ + public final String rawString; + + public ParsedType(String baseName, List typeArgs, String rawString) { + this.baseName = baseName; + this.typeArgs = typeArgs != null ? typeArgs : new ArrayList<>(); + this.rawString = rawString; + } + + /** + * Check if this parsed type has generic arguments. + */ + public boolean hasTypeArgs() { + return !typeArgs.isEmpty(); + } + + /** + * Get display string for debugging. + */ + @Override + public String toString() { + StringBuilder sb = new StringBuilder(baseName); + if (!typeArgs.isEmpty()) { + sb.append("<"); + for (int i = 0; i < typeArgs.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(typeArgs.get(i).toString()); + } + sb.append(">"); + } + return sb.toString(); + } + } + + /** + * Parse a type expression string into a ParsedType tree. + * + * @param typeExpr The type expression to parse (e.g., "List>[]") + * @return The parsed type, or null if parsing fails + */ + public static ParsedType parse(String typeExpr) { + if (typeExpr == null || typeExpr.isEmpty()) { + return null; + } + + String expr = typeExpr.trim(); + + // Now parse the base type with potential generic arguments + ParseResult result = parseTypeWithGenerics(expr, 0); + if (result == null || result.type == null) { + // Fallback: treat entire expression as a simple type + return new ParsedType(expr, null, typeExpr); + } + + return result.type; + } + + /** + * Internal result class for recursive parsing. + */ + private static class ParseResult { + final ParsedType type; + final int endIndex; // position after this type in the string + + ParseResult(ParsedType type, int endIndex) { + this.type = type; + this.endIndex = endIndex; + } + } + + /** + * Parse a type expression starting at the given index, handling nested generics. + * Returns the parsed type and the index after this type expression. + */ + private static ParseResult parseTypeWithGenerics(String expr, int startIndex) { + int i = startIndex; + int len = expr.length(); + + // Skip leading whitespace + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + if (i >= len) { + return null; + } + + // Read the base type name (can include dots for qualified names) + StringBuilder baseNameBuilder = new StringBuilder(); + while (i < len) { + char c = expr.charAt(i); + if (Character.isJavaIdentifierPart(c) || c == '.') { + baseNameBuilder.append(c); + i++; + } else { + break; + } + } + String baseName = baseNameBuilder.toString().trim(); + + if (baseName.isEmpty()) { + return null; + } + + // Remove trailing dots if any + while (baseName.endsWith(".")) { + baseName = baseName.substring(0, baseName.length() - 1); + } + + // Skip whitespace before potential < + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + // Check for generic arguments + List typeArgs = new ArrayList<>(); + if (i < len && expr.charAt(i) == '<') { + i++; // Skip '<' + + // Parse type arguments (comma-separated, handling nested <>) + while (i < len) { + // Skip whitespace + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + if (i >= len) break; + + char c = expr.charAt(i); + if (c == '>') { + i++; // Skip '>' + break; + } + + if (c == ',') { + i++; // Skip ',' + continue; + } + + // Parse a type argument (may itself have generics) + ParseResult argResult = parseTypeArgument(expr, i); + if (argResult != null && argResult.type != null) { + typeArgs.add(argResult.type); + i = argResult.endIndex; + } else { + // Skip forward until the next ',' or '>' at the current nesting level. + i = skipToNextTypeArgDelimiter(expr, i); + } + } + } + + ParsedType type = new ParsedType(baseName, typeArgs, expr.substring(startIndex, i)); + return new ParseResult(type, i); + } + + /** + * Skip forward until we hit ',' or '>' that would delimit a type argument. + * This prevents producing spurious additional type args when encountering unsupported syntax. + */ + private static int skipToNextTypeArgDelimiter(String expr, int startIndex) { + int i = startIndex; + int depth = 0; + boolean inString = false; + char stringChar = 0; + while (i < expr.length()) { + char c = expr.charAt(i); + if (inString) { + if (c == stringChar && (i == 0 || expr.charAt(i - 1) != '\\')) { + inString = false; + } + i++; + continue; + } + + if (c == '\'' || c == '"') { + inString = true; + stringChar = c; + i++; + continue; + } + + if (c == '<' || c == '(' || c == '[') { + depth++; + } else if (c == '>' || c == ')' || c == ']') { + if (depth > 0) { + depth--; + } else if (c == '>') { + return i; // Let caller handle closing '>' + } + } else if ((c == ',' || c == '>') && depth == 0) { + return i; + } + i++; + } + return i; + } + + /** + * Parse a single type argument, which may include nested generics and array suffixes. + */ + private static ParseResult parseTypeArgument(String expr, int startIndex) { + int i = startIndex; + int len = expr.length(); + + // Skip leading whitespace + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + if (i >= len) { + return null; + } + + // Check for wildcard (? extends X, ? super X, or just ?) + if (expr.charAt(i) == '?') { + i++; + // Skip whitespace + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + // Check for "extends" or "super" + if (i < len && expr.substring(i).startsWith("extends ")) { + i += 8; // Skip "extends " + // Parse the bound type + ParseResult boundResult = parseTypeWithGenerics(expr, i); + if (boundResult != null) { + return boundResult; + } + } else if (i < len && expr.substring(i).startsWith("super ")) { + i += 6; // Skip "super " + // Parse the bound type + ParseResult boundResult = parseTypeWithGenerics(expr, i); + if (boundResult != null) { + return boundResult; + } + } + // Plain wildcard, treat as Object + return new ParseResult(new ParsedType("Object", null, "?"), i); + } + + // Parse regular type with potential generics + ParseResult typeResult = parseTypeWithGenerics(expr, i); + if (typeResult == null) { + return null; + } + + i = typeResult.endIndex; + ParsedType baseType = typeResult.type; + + // Skip whitespace + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + + // Consume array suffixes / nullable suffix so the caller can correctly find delimiters. + while (i + 1 < len && expr.charAt(i) == '[' && expr.charAt(i + 1) == ']') { + i += 2; + while (i < len && Character.isWhitespace(expr.charAt(i))) { + i++; + } + } + if (i < len && expr.charAt(i) == '?') { + i++; + } + + // Preserve the full raw substring (including suffixes) for higher-level resolvers. + if (i != typeResult.endIndex) { + return new ParseResult(new ParsedType(baseType.baseName, baseType.typeArgs, expr.substring(startIndex, i)), i); + } + + return new ParseResult(baseType, i); + } + + /** + * Convenience method to check if a type string contains generic arguments. + */ + public static boolean hasGenerics(String typeExpr) { + return typeExpr != null && typeExpr.contains("<") && typeExpr.contains(">"); + } + + /** + * Strip generic arguments from a type string (for base type lookup). + * Example: "List" -> "List" + */ + public static String stripGenerics(String typeExpr) { + if (typeExpr == null) return null; + int idx = typeExpr.indexOf('<'); + if (idx > 0) { + return typeExpr.substring(0, idx).trim(); + } + return typeExpr; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ImportData.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ImportData.java new file mode 100644 index 000000000..cda544a98 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ImportData.java @@ -0,0 +1,183 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.interpreter.token.Token; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a single import statement with all its metadata. + * Tracks the full import path, resolution status, source positions, and references. + */ +public final class ImportData { + + private final String fullPath; // e.g., "java.util.List" or "kamkeel.npcdbc.api.*" + private final String simpleName; // e.g., "List" or null for wildcard + private final boolean isWildcard; // true if ends with .* + private final boolean isStatic; // true if 'import static' + private final int startOffset; // start of 'import' keyword in source + private final int endOffset; // end of import statement (before or at semicolon) + private final int pathStartOffset; // start of the package.Class path + private final int pathEndOffset; // end of the package.Class path (before .*) + + private TypeInfo resolvedType; // null if wildcard or unresolved + private boolean resolved; // whether resolution was attempted and succeeded + + // Track usage count (simpler than tracking individual tokens) + private int usageCount = 0; + + // Track all tokens that reference this import (optional, for detailed analysis) + private final List referencingTokens = new ArrayList<>(); + + public ImportData(String fullPath, String simpleName, boolean isWildcard, boolean isStatic, + int startOffset, int endOffset, int pathStartOffset, int pathEndOffset) { + this.fullPath = fullPath; + this.simpleName = simpleName; + this.isWildcard = isWildcard; + this.isStatic = isStatic; + this.startOffset = startOffset; + this.endOffset = endOffset; + this.pathStartOffset = pathStartOffset; + this.pathEndOffset = pathEndOffset; + this.resolved = false; + } + + // Getters + public String getFullPath() { return fullPath; } + public String getSimpleName() { return simpleName; } + public boolean isWildcard() { return isWildcard; } + public boolean isStatic() { return isStatic; } + public int getStartOffset() { return startOffset; } + public int getEndOffset() { return endOffset; } + public int getPathStartOffset() { return pathStartOffset; } + public int getPathEndOffset() { return pathEndOffset; } + public TypeInfo getResolvedType() { return resolvedType; } + public boolean isResolved() { return resolved; } + + /** + * Set the resolved type information. + */ + public void setResolvedType(TypeInfo typeInfo) { + this.resolvedType = typeInfo; + this.resolved = typeInfo != null && typeInfo.isResolved(); + } + + /** + * Mark this import as resolved (for wildcard imports where we confirm the package exists). + */ + public void markResolved(boolean resolved) { + this.resolved = resolved; + } + + // ==================== REFERENCE TRACKING ==================== + + /** + * Increment the usage count for this import. + * Called during type resolution when this import is used. + */ + public void incrementUsage() { + usageCount++; + } + + /** + * Add a token that references this import. + * Called when a token is created that uses this import's type. + */ + public void addReference(Token token) { + if (token != null && !referencingTokens.contains(token)) { + referencingTokens.add(token); + } + } + + /** + * Get all tokens that reference this import. + */ + public List getReferencingTokens() { + return Collections.unmodifiableList(referencingTokens); + } + + /** + * Check if this import is used (has any references or usages). + */ + public boolean isUsed() { + return usageCount > 0 || !referencingTokens.isEmpty(); + } + + /** + * Get the usage count for this import. + */ + public int getUsageCount() { + return usageCount; + } + + /** + * Get the number of references to this import. + */ + public int getReferenceCount() { + return referencingTokens.size(); + } + + /** + * Clear all references and reset usage count (used when re-tokenizing). + */ + public void clearReferences() { + referencingTokens.clear(); + usageCount = 0; + } + + /** + * Get the package portion of the import path. + * For "java.util.List" returns "java.util" + * For wildcards like "java.util.*" returns "java.util" + */ + public String getPackagePortion() { + if (isWildcard) { + return fullPath; + } + int lastDot = fullPath.lastIndexOf('.'); + return lastDot > 0 ? fullPath.substring(0, lastDot) : ""; + } + + /** + * Get all segments of the import path. + */ + public String[] getPathSegments() { + return fullPath.split("\\."); + } + + /** + * Check if this import could provide the given simple class name. + * For wildcard imports, always returns true (might provide any class). + * For specific imports, checks if the simple name matches. + */ + public boolean couldProvide(String className) { + if (isWildcard) { + return true; + } + return className.equals(simpleName); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("import "); + if (isStatic) sb.append("static "); + sb.append(fullPath); + if (isWildcard) sb.append(".*"); + sb.append(" [").append(resolved ? "resolved" : "unresolved").append("]"); + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ImportData that = (ImportData) o; + return fullPath.equals(that.fullPath) && isWildcard == that.isWildcard && isStatic == that.isStatic; + } + + @Override + public int hashCode() { + return fullPath.hashCode() * 31 + (isWildcard ? 1 : 0) + (isStatic ? 2 : 0); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/NashornBuiltins.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/NashornBuiltins.java new file mode 100644 index 000000000..962b5b11a --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/NashornBuiltins.java @@ -0,0 +1,282 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticMethod; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticParameter; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticTypeBuilder; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticType; + +import java.util.*; + +/** + * Registry for Nashorn built-in types and global functions. + * + *

Nashorn provides several built-in objects and functions:

+ *
    + *
  • Java - Object for Java interop: type(), extend(), from(), to()
  • + *
  • Packages - Root of package hierarchy for accessing Java packages
  • + *
  • JavaImporter - Creates scope with imported Java packages
  • + *
  • print() - Global print function
  • + *
  • load() - Load and execute a script
  • + *
+ */ +public class NashornBuiltins { + + private static NashornBuiltins instance; + + private final Map builtinTypes = new LinkedHashMap<>(); + private final Map globalFunctions = new LinkedHashMap<>(); + + private NashornBuiltins() { + initializeBuiltins(); + } + + public static NashornBuiltins getInstance() { + if (instance == null) { + instance = new NashornBuiltins(); + } + return instance; + } + + private void initializeBuiltins() { + // ==================== Java object ==================== + SyntheticType javaType = new SyntheticTypeBuilder("Java") + .documentation("Nashorn's Java interop object for accessing Java types and utilities.") + + .addMethod("type") + .parameter("className", "String", "Fully-qualified Java class name") + .returns("java.lang.Class") + .returnsResolved(args -> { + if (args != null && args.length > 0 && args[0] != null) { + String className = args[0]; + // Remove quotes if present + if (className.startsWith("\"") && className.endsWith("\"")) { + className = className.substring(1, className.length() - 1); + } else if (className.startsWith("'") && className.endsWith("'")) { + className = className.substring(1, className.length() - 1); + } + // Try to load the class + TypeInfo resolved = TypeResolver.getInstance().resolve(className); + if (resolved != null && resolved.isResolved()) { + return new ClassTypeInfo(resolved); + } + } + return null; + }) + .documentation("Loads a Java class by its fully-qualified name.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "var File = Java.type(\"java.io.File\");\n" + + "var file = new File(\"path/to/file\");\n" + + "```\n\n" + + "@param className The fully-qualified Java class name\n" + + "@returns The Java class reference (use with 'new' to create instances)") + .done() + + .addMethod("extend") + .parameter("type", "java.lang.Class", "Java class or interface to extend/implement") + .returns("java.lang.Class") + .documentation("Creates a subclass or implementation of a Java class or interface.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "var MyRunnable = Java.extend(Java.type(\"java.lang.Runnable\"), {\n" + + " run: function() {\n" + + " print(\"Running!\");\n" + + " }\n" + + "});\n" + + "```\n\n" + + "@param type The Java class or interface to extend\n" + + "@returns A new class that extends/implements the given type") + .done() + + .addMethod("from") + .parameter("javaArray", "Object", "A Java array or Collection") + .returns("Object[]") + .documentation("Converts a Java array or Collection to a JavaScript array.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "var jsList = Java.from(javaArrayList);\n" + + "jsList.forEach(function(item) { print(item); });\n" + + "```\n\n" + + "@param javaArray A Java array or java.util.Collection\n" + + "@returns A JavaScript array containing the elements") + .done() + + .addMethod("to") + .parameter("jsArray", "Object[]", "A JavaScript array") + .parameter("javaType", "Class", "The target Java array type") + .returns("Object") + .documentation("Converts a JavaScript array to a Java array of the specified type.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "var jsArray = [1, 2, 3];\n" + + "var intArray = Java.to(jsArray, \"int[]\");\n" + + "```\n\n" + + "@param jsArray A JavaScript array\n" + + "@param javaType The target Java array type (e.g., \"int[]\", \"java.lang.String[]\")\n" + + "@returns A Java array of the specified type") + .done() + + .addMethod("super") + .parameter("object", "Object", "A Java object created via Java.extend()") + .returns("Object") + .documentation("Gets a reference to the super class for calling super methods.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "var MyList = Java.extend(Java.type(\"java.util.ArrayList\"), {\n" + + " add: function(e) {\n" + + " print(\"Adding: \" + e);\n" + + " return Java.super(this).add(e);\n" + + " }\n" + + "});\n" + + "```\n\n" + + "@param object An extended Java object\n" + + "@returns A reference to call super methods") + .done() + + .addMethod("synchronized") + .parameter("func", "java.util.function.Function", "The function to synchronize") + .parameter("lock", "Object", "The object to synchronize on") + .returns("java.util.function.Function") + .documentation("Wraps a function to execute synchronized on a given object.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "var syncFunc = Java.synchronized(function() {\n" + + " // Thread-safe code here\n" + + "}, lockObject);\n" + + "```\n\n" + + "@param func The function to synchronize\n" + + "@param lock The object to use as the monitor\n" + + "@returns A synchronized version of the function") + .done() + + .build(); + builtinTypes.put("Java", javaType); + + // ==================== Global print function ==================== + SyntheticType printFunc = new SyntheticTypeBuilder("print") + .addMethod("print") + .parameter("message", "Object", "The message to print") + .returns("void") + .documentation("Prints a message to standard output.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "print(\"Hello, world!\");\n" + + "print(myObject);\n" + + "```") + .done() + .build(); + globalFunctions.put("print", printFunc.getMethod("print")); + + // ==================== Global load function ==================== + SyntheticType loadFunc = new SyntheticTypeBuilder("load") + .addMethod("load") + .parameter("script", "String", "Path or URL to the script") + .returns("Object") + .documentation("Loads and executes a script file or URL.\n\n" + + "**Usage:**\n" + + "```javascript\n" + + "load(\"./myScript.js\");\n" + + "load(\"http://example.com/script.js\");\n" + + "```\n\n" + + "@param script Path to a local script or URL\n" + + "@returns The result of the script execution") + .done() + .build(); + globalFunctions.put("load", loadFunc.getMethod("load")); + } + + /** + * Get a built-in type by name. + * @param name The type name (e.g., "Java") + * @return The SyntheticType or null if not found + */ + public SyntheticType getBuiltinType(String name) { + return builtinTypes.get(name); + } + + /** + * Check if a name is a built-in type. + */ + public boolean isBuiltinType(String name) { + return builtinTypes.containsKey(name); + } + + /** + * Get a global function by name. + */ + public SyntheticMethod getGlobalFunction(String name) { + return globalFunctions.get(name); + } + + /** + * Check if a name is a global function. + */ + public boolean isGlobalFunction(String name) { + return globalFunctions.containsKey(name); + } + + /** + * Get all built-in type names. + */ + public Set getBuiltinTypeNames() { + return Collections.unmodifiableSet(builtinTypes.keySet()); + } + + /** + * Get all global function names. + */ + public Set getGlobalFunctionNames() { + return Collections.unmodifiableSet(globalFunctions.keySet()); + } + + /** + * Get all built-in types. + */ + public Collection getAllBuiltinTypes() { + return Collections.unmodifiableCollection(builtinTypes.values()); + } + + /** + * Get all global functions as SyntheticTypes. + * Each function is wrapped in its own SyntheticType for the registry. + */ + public Map getAllGlobalFunctions() { + Map result = new LinkedHashMap<>(); + + // Wrap each global function in a minimal SyntheticType + for (Map.Entry entry : globalFunctions.entrySet()) { + String name = entry.getKey(); + SyntheticMethod method = entry.getValue(); + + // Create a synthetic type that just contains this one global function + SyntheticTypeBuilder builder = new SyntheticTypeBuilder(name); + builder.documentation("Global " + name + " function"); + + // Add the method + SyntheticTypeBuilder.MethodBuilder methodBuilder = builder.addMethod(name); + for (SyntheticParameter param : method.parameters) { + methodBuilder.parameter(param.name, param.typeName, param.documentation); + } + methodBuilder.returns(method.returnType); + if (method.documentation != null && !method.documentation.isEmpty()) { + methodBuilder.documentation(method.documentation); + } + methodBuilder.done(); + + result.put(name, builder.build()); + } + + return result; + } + + /** + * Resolve the return type of Java.type() given the class name argument. + */ + public TypeInfo resolveJavaType(String className) { + SyntheticType javaBuiltin = builtinTypes.get("Java"); + if (javaBuiltin != null) { + return javaBuiltin.resolveMethodReturnType("type", new String[]{className}); + } + return null; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/OverloadSelector.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/OverloadSelector.java new file mode 100644 index 000000000..4bd105aa8 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/OverloadSelector.java @@ -0,0 +1,296 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; + +import java.util.List; + +/** + * Helper class for selecting the best method overload from a list of candidates. + * + * This encapsulates the scoring-based overload selection logic that considers: + * - Exact type matches + * - Numeric promotion compatibility + * - Arity (parameter count) matching + * - Varargs handling + * - Unknown (null) argument types + * + * Lower scores indicate better matches. + */ +public class OverloadSelector { + + // Scoring constants + private static final int ARITY_MISMATCH_BASE = 10000; + private static final int VARARGS_PENALTY = 1000; + private static final int UNKNOWN_TYPE_PENALTY = 10; + private static final int COMPATIBLE_TYPE_PENALTY = 5; + private static final int INCOMPATIBLE_TYPE_PENALTY = 100; + + /** + * Select the best overload from a list of candidates based on argument types. + * + * Selection phases: + * 1. Exact match (all types match exactly) + * 2. Numeric promotion match (all args can promote to common param type) + * 3. Scoring-based selection (handles unknown types, varargs, etc.) + * + * @param overloads List of candidate method overloads + * @param argTypes Array of argument types (may contain nulls for unknown types) + * @return The best matching overload, or the first overload if no good match found + */ + public static MethodInfo selectBestOverload(List overloads, TypeInfo[] argTypes) { + if (overloads == null || overloads.isEmpty()) return null; + + int argCount = (argTypes == null) ? 0 : argTypes.length; + + // Fast path: If no arguments provided, try to find zero-arg method + if (argCount == 0) { + for (MethodInfo method : overloads) { + if (method.getParameterCount() == 0) { + return method; + } + } + // Fall back to closest arity + return selectClosestArity(overloads, 0); + } + + // Check if all arg types are known (non-null) + boolean allArgsKnown = true; + for (TypeInfo argType : argTypes) { + if (argType == null) { + allArgsKnown = false; + break; + } + } + + // Phase 1: Try exact match (only when all args known) + if (allArgsKnown) { + MethodInfo exactMatch = findExactMatch(overloads, argTypes, argCount); + if (exactMatch != null) return exactMatch; + } + + // Phase 2: Try numeric promotion match (only when all args known) + if (allArgsKnown) { + MethodInfo numericMatch = findNumericPromotionMatch(overloads, argTypes, argCount); + if (numericMatch != null) return numericMatch; + } + + // Phase 3: Scoring-based selection with arity-first preference + // This handles unknown (null) arg types gracefully and prefers fixed-arity over varargs + return scoringBasedSelection(overloads, argTypes, argCount); + } + + /** + * Find an overload with exact type match for all arguments. + */ + private static MethodInfo findExactMatch(List overloads, TypeInfo[] argTypes, int argCount) { + for (MethodInfo method : overloads) { + if (method.getParameterCount() == argCount) { + boolean exactMatch = true; + List params = method.getParameters(); + for (int i = 0; i < argCount; i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + TypeInfo argType = argTypes[i]; + if (paramType == null || !paramType.equals(argType)) { + exactMatch = false; + break; + } + } + if (exactMatch) return method; + } + } + return null; + } + + /** + * Find an overload where all numeric arguments can promote to a common parameter type. + * Prefers narrower numeric types (e.g., int over long over double). + */ + private static MethodInfo findNumericPromotionMatch(List overloads, TypeInfo[] argTypes, int argCount) { + MethodInfo bestNumericMatch = null; + int bestNumericRank = Integer.MAX_VALUE; + + for (MethodInfo method : overloads) { + if (method.getParameterCount() == argCount) { + List params = method.getParameters(); + + // Check if all parameters and arguments are numeric primitives + boolean allNumeric = true; + for (int i = 0; i < argCount; i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + TypeInfo argType = argTypes[i]; + if (paramType == null || + !TypeChecker.isNumericPrimitive(paramType) || !TypeChecker.isNumericPrimitive(argType)) { + allNumeric = false; + break; + } + } + + if (allNumeric) { + // Check if all parameters are the same numeric type + TypeInfo commonParamType = params.get(0).getTypeInfo(); + boolean allParamsSame = true; + for (int i = 1; i < params.size(); i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + if (!paramType.equals(commonParamType)) { + allParamsSame = false; + break; + } + } + + // If all parameters are the same numeric type, check if args can promote to it + if (allParamsSame) { + boolean canPromote = true; + for (int i = 0; i < argCount; i++) { + if (!TypeChecker.canPromoteNumeric(argTypes[i], commonParamType)) { + canPromote = false; + break; + } + } + + // If all args can promote, check if this is the narrowest match so far + if (canPromote) { + int paramRank = TypeChecker.getNumericRank(commonParamType.getJavaClass()); + if (paramRank < bestNumericRank) { + bestNumericRank = paramRank; + bestNumericMatch = method; + } + } + } + } + } + } + + return bestNumericMatch; + } + + /** + * Use scoring to select the best overload when exact/numeric matches don't apply. + */ + private static MethodInfo scoringBasedSelection(List overloads, TypeInfo[] argTypes, int argCount) { + MethodInfo bestCandidate = null; + int bestScore = Integer.MAX_VALUE; + + for (MethodInfo method : overloads) { + int score = scoreOverload(method, argTypes, argCount); + if (score < bestScore) { + bestScore = score; + bestCandidate = method; + } + } + + return (bestCandidate != null) ? bestCandidate : overloads.get(0); + } + + /** + * Score an overload for matching against provided arguments. + * Lower score = better match. + * + * Scoring priorities: + * - Arity mismatch: +10000 + |paramCount - argCount| + * - Varargs penalty: +1000 + * - Unknown arg type (null): +10 per arg + * - Compatible but not exact: +5 per arg + * - Incompatible type: +100 per arg + * - Exact type match: +0 per arg + * + * @param method The method overload to score + * @param argTypes Array of argument types (may contain nulls) + * @param argCount Number of arguments + * @return Score (lower is better) + */ + public static int scoreOverload(MethodInfo method, TypeInfo[] argTypes, int argCount) { + int score = 0; + int paramCount = method.getParameterCount(); + + // Check if method is varargs + boolean isVarArgs = false; + java.lang.reflect.Method javaMethod = method.getJavaMethod(); + if (javaMethod != null) { + isVarArgs = javaMethod.isVarArgs(); + } + + // Determine arity applicability + boolean arityApplicable; + if (isVarArgs) { + // Varargs methods accept argCount >= paramCount - 1 + // (the varargs array can be empty or have multiple elements) + arityApplicable = argCount >= paramCount - 1; + } else { + arityApplicable = paramCount == argCount; + } + + if (!arityApplicable) { + // Large penalty for arity mismatch, plus distance to encourage closest match + score += ARITY_MISMATCH_BASE + Math.abs(paramCount - argCount); + } + + // Varargs penalty (fixed-arity preferred over varargs when both apply) + if (isVarArgs) { + score += VARARGS_PENALTY; + } + + // Score each argument's type compatibility + List params = method.getParameters(); + int paramsToCheck = Math.min(argCount, paramCount); + + for (int i = 0; i < paramsToCheck; i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + TypeInfo argType = (argTypes != null && i < argTypes.length) ? argTypes[i] : null; + + if (argType == null) { + // Unknown arg type - don't disqualify, but add penalty + score += UNKNOWN_TYPE_PENALTY; + } else if (paramType == null) { + // Unknown param type - add penalty + score += UNKNOWN_TYPE_PENALTY; + } else if (paramType.equals(argType)) { + // Exact match - no penalty + score += 0; + } else if (TypeChecker.isTypeCompatible(paramType, argType)) { + // Compatible but not exact + score += COMPATIBLE_TYPE_PENALTY; + } else { + // Incompatible type + score += INCOMPATIBLE_TYPE_PENALTY; + } + } + + // Penalize extra args that don't match params (for non-varargs) + if (!isVarArgs && argCount > paramCount) { + score += (argCount - paramCount) * INCOMPATIBLE_TYPE_PENALTY; + } + + return score; + } + + /** + * Select the overload with closest arity to the target. + * Tie-breaker: lower parameter count wins. + * + * @param overloads List of candidate overloads + * @param targetArity The desired number of parameters + * @return The overload with closest arity, or first overload if list is empty + */ + public static MethodInfo selectClosestArity(List overloads, int targetArity) { + if (overloads == null || overloads.isEmpty()) return null; + + MethodInfo best = null; + int bestDistance = Integer.MAX_VALUE; + int bestParamCount = Integer.MAX_VALUE; + + for (MethodInfo method : overloads) { + int paramCount = method.getParameterCount(); + int distance = Math.abs(paramCount - targetArity); + + // Prefer smaller distance; tie-break by lower paramCount + if (distance < bestDistance || (distance == bestDistance && paramCount < bestParamCount)) { + best = method; + bestDistance = distance; + bestParamCount = paramCount; + } + } + + return (best != null) ? best : overloads.get(0); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java new file mode 100644 index 000000000..afb66fafd --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java @@ -0,0 +1,929 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +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.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodSignature; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a type defined within the script itself (not from Java classpath). + * This handles classes, interfaces, and enums declared in the user's script. + * + * Unlike TypeInfo which wraps a Java Class, ScriptTypeInfo stores all metadata + * about fields, methods, and other type information parsed from the script. + */ +public class ScriptTypeInfo extends TypeInfo { + + private final String scriptClassName; + private final int declarationOffset; + private final int bodyStart; + private final int bodyEnd; + private final int modifiers; // Java reflection modifiers (public, static, final, etc.) + + // Script-defined members + private final Map fields = new HashMap<>(); + private final Map> methods = new HashMap<>(); // name -> list of overloads + private final List constructors = new ArrayList<>(); // List of constructors + private final List innerClasses = new ArrayList<>(); + + // Enum constants (for enum types only) - name -> EnumConstantInfo + private final Map enumConstants = new HashMap<>(); + + // Parent class reference (for inner class resolution) + private ScriptTypeInfo outerClass; + + // ==================== INHERITANCE ==================== + + /** + * The parent/super class for this type (from "extends ParentClass"). + * Can be a resolved TypeInfo or ScriptTypeInfo, or unresolved TypeInfo if the parent is not found. + * Null if this type doesn't extend anything (or extends Object implicitly). + */ + private TypeInfo superClass; + + /** + * The raw string name of the super class as written in the script (e.g., "ParentClass"). + * Stored for display purposes even when the type couldn't be resolved. + */ + private String superClassName; + + /** + * All implemented interfaces (from "implements Interface1, Interface2, ..."). + * Each can be resolved or unresolved. The list order matches the declaration order. + */ + private final List implementedInterfaces = new ArrayList<>(); + + /** + * The raw string names of implemented interfaces as written in the script. + * Stored for display purposes even when types couldn't be resolved. + */ + private final List implementedInterfaceNames = new ArrayList<>(); + + private ScriptTypeInfo(String simpleName, String fullName, Kind kind, + int declarationOffset, int bodyStart, int bodyEnd, int modifiers) { + super(simpleName, fullName, "", kind, null, true, null, true); + this.scriptClassName = simpleName; + this.declarationOffset = declarationOffset; + this.bodyStart = bodyStart; + this.bodyEnd = bodyEnd; + this.modifiers = modifiers; + } + + // Factory method + public static ScriptTypeInfo create(String simpleName, Kind kind, + int declarationOffset, int bodyStart, int bodyEnd, int modifiers) { + return new ScriptTypeInfo(simpleName, simpleName, kind, declarationOffset, bodyStart, bodyEnd, modifiers); + } + + public static ScriptTypeInfo createInner(String simpleName, Kind kind, ScriptTypeInfo outer, + int declarationOffset, int bodyStart, int bodyEnd, int modifiers) { + String fullName = outer.getFullName() + "$" + simpleName; + ScriptTypeInfo inner = new ScriptTypeInfo(simpleName, fullName, kind, declarationOffset, bodyStart, bodyEnd, modifiers); + inner.outerClass = outer; + outer.innerClasses.add(inner); + return inner; + } + + // Getters + public String getScriptClassName() { return scriptClassName; } + public int getDeclarationOffset() { return declarationOffset; } + public int getBodyStart() { return bodyStart; } + public int getBodyEnd() { return bodyEnd; } + public int getModifiers() { return modifiers; } + public ScriptTypeInfo getOuterClass() { return outerClass; } + public List getInnerClasses() { return innerClasses; } + + // ==================== INHERITANCE ==================== + + /** + * Get the super class (from "extends"). Can be resolved or unresolved. + * @return The parent class TypeInfo, or null if no extends clause + */ + public TypeInfo getSuperClass() { return superClass; } + + /** + * Get the raw super class name as written in the script. + * @return The super class name string, or null if no extends clause + */ + public String getSuperClassName() { return superClassName; } + + /** + * Set the super class info. Call this after resolving types. + * @param superClass The resolved or unresolved TypeInfo for the parent class + * @param superClassName The raw class name as written in the script + */ + public void setSuperClass(TypeInfo superClass, String superClassName) { + this.superClass = superClass; + this.superClassName = superClassName; + } + + /** + * Check if this type has a super class (extends something). + */ + public boolean hasSuperClass() { return superClass != null || superClassName != null; } + + /** + * Get all implemented interfaces. Each can be resolved or unresolved. + * @return Unmodifiable list of implemented interface TypeInfos + */ + public List getImplementedInterfaces() { + return new ArrayList<>(implementedInterfaces); + } + + /** + * Get the raw interface names as written in the script. + * @return Unmodifiable list of interface name strings + */ + public List getImplementedInterfaceNames() { + return new ArrayList<>(implementedInterfaceNames); + } + + /** + * Add an implemented interface. Call this after resolving types. + * @param interfaceType The resolved or unresolved TypeInfo for the interface + * @param interfaceName The raw interface name as written in the script + */ + public void addImplementedInterface(TypeInfo interfaceType, String interfaceName) { + implementedInterfaces.add(interfaceType); + implementedInterfaceNames.add(interfaceName); + } + + /** + * Check if this type implements any interfaces. + */ + public boolean hasImplementedInterfaces() { return !implementedInterfaces.isEmpty(); } + + /** + * Check if this type implements a specific interface (by simple name). + */ + public boolean implementsInterface(String interfaceName) { + for (String name : implementedInterfaceNames) { + if (name.equals(interfaceName)) return true; + } + for (TypeInfo ti : implementedInterfaces) { + if (ti.getSimpleName().equals(interfaceName)) return true; + } + return false; + } + + /** + * Check if a position is inside this type's body. + */ + public boolean containsPosition(int position) { + return position >= bodyStart && position < bodyEnd; + } + + // ==================== FIELD MANAGEMENT ==================== + + public void addField(FieldInfo field) { + fields.put(field.getName(), field); + } + + @Override + public boolean hasField(String fieldName) { + if (fields.containsKey(fieldName)) + return true; + + if (enumConstants.containsKey(fieldName)) + return true; + + return false; + } + + @Override + public FieldInfo getFieldInfo(String fieldName) { + if (fields.containsKey(fieldName)) + return fields.get(fieldName); + + if (enumConstants.containsKey(fieldName)) { + EnumConstantInfo enumConst = enumConstants.get(fieldName); + if (enumConst != null) + return enumConst.getFieldInfo(); + } + + return null; + } + + public Map getFields() { + return new HashMap<>(fields); + } + + // ==================== ENUM CONSTANT MANAGEMENT ==================== + + /** + * Add an enum constant to this enum type. + * Only valid for enum types. + */ + public void addEnumConstant(EnumConstantInfo constant) { + if (isEnum()) { + enumConstants.put(constant.getFieldInfo().getName(), constant); + } + } + + /** + * Check if this enum has a constant with the given name. + */ + public boolean hasEnumConstant(String constantName) { + return enumConstants.containsKey(constantName); + } + + /** + * Get an enum constant by name. + */ + public EnumConstantInfo getEnumConstant(String constantName) { + return enumConstants.get(constantName); + } + + /** + * Get all enum constants. + */ + public Map getEnumConstants() { + return new HashMap<>(enumConstants); + } + + /** + * Check if this is an enum type and has any constants. + */ + public boolean hasEnumConstants() { + return isEnum() && !enumConstants.isEmpty(); + } + + // ==================== METHOD MANAGEMENT ==================== + + public void addMethod(MethodInfo method) { + methods.computeIfAbsent(method.getName(), k -> new ArrayList<>()).add(method); + } + + @Override + public boolean hasMethod(String methodName) { + return methods.containsKey(methodName); + } + + @Override + public boolean hasMethod(String methodName, int paramCount) { + List overloads = methods.get(methodName); + if (overloads == null) return false; + for (MethodInfo m : overloads) { + if (m.getParameterCount() == paramCount) { + return true; + } + } + return false; + } + + @Override + public MethodInfo getMethodInfo(String methodName) { + List overloads = methods.get(methodName); + return (overloads != null && !overloads.isEmpty()) ? overloads.get(0) : null; + } + + /** + * Get all method overloads with the given name. + */ + public List getAllMethodOverloads(String methodName) { + return methods.getOrDefault(methodName, new ArrayList<>()); + } + + /** + * Get a method with specific parameter count. + */ + public MethodInfo getMethodWithParamCount(String methodName, int paramCount) { + List overloads = methods.get(methodName); + if (overloads == null) return null; + for (MethodInfo m : overloads) { + if (m.getParameterCount() == paramCount) { + return m; + } + } + return null; + } + + public Map> getMethods() { + return new HashMap<>(methods); + } + + public List getAllMethodsFlat() { + List allMethods = new ArrayList<>(); + for (List overloads : methods.values()) { + allMethods.addAll(overloads); + } + return allMethods; + } + + // ==================== CONSTRUCTOR MANAGEMENT ==================== + + public void addConstructor(MethodInfo constructor) { + constructors.add(constructor); + } + + @Override + public List getConstructors() { + return new ArrayList<>(constructors); + } + + @Override + public boolean hasConstructors() { + return !constructors.isEmpty(); + } + + /** + * Find the best matching constructor for the given argument count. + * Returns null if no constructor matches. + */ + @Override + public MethodInfo findConstructor(int argCount) { + for (MethodInfo constructor : constructors) { + if (constructor.getParameterCount() == argCount) { + return constructor; + } + } + return null; + } + + public MethodInfo findConstructor(TypeInfo[] argTypes) { + for (MethodInfo constructor : constructors) { + if (constructor.getParameterCount() == argTypes.length) { + boolean match = true; + List params = constructor.getParameters(); + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + if (!TypeChecker.isTypeCompatible(paramType, argTypes[i])) { + match = false; + break; + } + } + if (match) + return constructor; + } + } + return null; + } + + // ==================== INHERITANCE HIERARCHY SEARCH ==================== + + /** + * Check if this type or any of its parent classes has a field with the given name. + * This recursively searches up the inheritance tree. + */ + public boolean hasFieldInHierarchy(String fieldName) { + // Check this class first + if (hasField(fieldName)) { + return true; + } + + // Check parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + return ((ScriptTypeInfo) superClass).hasFieldInHierarchy(fieldName); + } else { + // For Java classes, hasField already checks inheritance via reflection + return superClass.hasField(fieldName); + } + } + + return false; + } + + /** + * Get field info from this type or any of its parent classes. + * This recursively searches up the inheritance tree. + */ + public FieldInfo getFieldInfoInHierarchy(String fieldName) { + // Check this class first + FieldInfo field = getFieldInfo(fieldName); + if (field != null) { + return field; + } + + // Check parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + return ((ScriptTypeInfo) superClass).getFieldInfoInHierarchy(fieldName); + } else { + // For Java classes, getFieldInfo already checks inheritance via reflection + return superClass.getFieldInfo(fieldName); + } + } + + return null; + } + + /** + * Check if this type or any of its parent classes has a method with the given name. + * This recursively searches up the inheritance tree. + */ + public boolean hasMethodInHierarchy(String methodName) { + // Check this class first + if (hasMethod(methodName)) { + return true; + } + + // Check parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + return ((ScriptTypeInfo) superClass).hasMethodInHierarchy(methodName); + } else { + // For Java classes, hasMethod already checks inheritance via reflection + return superClass.hasMethod(methodName); + } + } + + return false; + } + + /** + * Check if this type or any of its parent classes has a method with the given name and parameter count. + * This recursively searches up the inheritance tree. + */ + public boolean hasMethodInHierarchy(String methodName, int paramCount) { + // Check this class first + if (hasMethod(methodName, paramCount)) { + return true; + } + + // Check parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + return ((ScriptTypeInfo) superClass).hasMethodInHierarchy(methodName, paramCount); + } else { + // For Java classes, hasMethod already checks inheritance via reflection + return superClass.hasMethod(methodName, paramCount); + } + } + + return false; + } + + /** + * Get method info from this type or any of its parent classes. + * This recursively searches up the inheritance tree. + */ + public MethodInfo getMethodInfoInHierarchy(String methodName) { + // Check this class first + MethodInfo method = getMethodInfo(methodName); + if (method != null) { + return method; + } + + // Check parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + return ((ScriptTypeInfo) superClass).getMethodInfoInHierarchy(methodName); + } else { + // For Java classes, getMethodInfo already checks inheritance via reflection + return superClass.getMethodInfo(methodName); + } + } + + return null; + } + + /** + * Get method info with specific parameter count from this type or any of its parent classes. + * This recursively searches up the inheritance tree. + */ + public MethodInfo getMethodWithParamCountInHierarchy(String methodName, int paramCount) { + // Check this class first + MethodInfo method = getMethodWithParamCount(methodName, paramCount); + if (method != null) { + return method; + } + + // Check parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + return ((ScriptTypeInfo) superClass).getMethodWithParamCountInHierarchy(methodName, paramCount); + } else { + // For Java classes, reflection already handles inheritance + // We need to manually search for matching method + List overloads = superClass.getAllMethodOverloads(methodName); + for (MethodInfo m : overloads) { + if (m.getParameterCount() == paramCount) { + return m; + } + } + } + } + + return null; + } + + /** + * Get all method overloads with the given name from this type or any of its parent classes. + * This recursively searches up the inheritance tree and returns all matching overloads. + */ + public List getAllMethodOverloadsInHierarchy(String methodName) { + List result = new ArrayList<>(); + + // Get overloads from this class + result.addAll(getAllMethodOverloads(methodName)); + + // Get overloads from parent class recursively + if (superClass != null && superClass.isResolved()) { + if (superClass instanceof ScriptTypeInfo) { + result.addAll(((ScriptTypeInfo) superClass).getAllMethodOverloadsInHierarchy(methodName)); + } else { + // For Java classes, getAllMethodOverloads already checks inheritance via reflection + result.addAll(superClass.getAllMethodOverloads(methodName)); + } + } + + return result; + } + + /** + * Override getBestMethodOverload to search the inheritance hierarchy. + * This ensures that method overload resolution considers parent class methods. + */ + @Override + public MethodInfo getBestMethodOverload(String methodName, TypeInfo[] argTypes) { + // Get all overloads from this class and parent classes + List allOverloads = getAllMethodOverloadsInHierarchy(methodName); + if (allOverloads.isEmpty()) + return null; + + // If no arguments provided, try to find zero-arg method + if (argTypes == null || argTypes.length == 0) { + for (MethodInfo method : allOverloads) { + if (method.getParameterCount() == 0) { + return method; + } + } + // Fall back to first overload if no zero-arg found + return allOverloads.get(0); + } + + // Phase 1: Try exact match + for (MethodInfo method : allOverloads) { + if (method.getParameterCount() == argTypes.length) { + boolean exactMatch = true; + List params = method.getParameters(); + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + TypeInfo argType = argTypes[i]; + if (paramType == null || argType == null || !paramType.equals(argType)) { + exactMatch = false; + break; + } + } + if (exactMatch) + return method; + } + } + + // Phase 2: Try compatible type match (assignability) + for (MethodInfo method : allOverloads) { + if (method.getParameterCount() == argTypes.length) { + boolean compatible = true; + List params = method.getParameters(); + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + TypeInfo argType = argTypes[i]; + if (paramType == null || argType == null) { + continue; // Allow null types (unresolved) + } + // Check if argType can be assigned to paramType + if (!noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker.isTypeCompatible(paramType, + argType)) { + compatible = false; + break; + } + } + if (compatible) + return method; + } + } + + // Phase 3: Fall back to first overload with matching parameter count + for (MethodInfo method : allOverloads) { + if (method.getParameterCount() == argTypes.length) { + return method; + } + } + + // No match found, return first overload + return allOverloads.get(0); + } + + /** + * Override getBestMethodOverload with return type expectation to search the inheritance hierarchy. + */ + @Override + public MethodInfo getBestMethodOverload(String methodName, TypeInfo expectedReturnType) { + List allOverloads = getAllMethodOverloadsInHierarchy(methodName); + if (allOverloads.isEmpty()) + return null; + + // If no expected return type, return first overload + if (expectedReturnType == null) { + return allOverloads.get(0); + } + + // First pass: look for return type compatible overload + for (MethodInfo method : allOverloads) { + TypeInfo returnType = method.getReturnType(); + if (returnType != null && noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker.isTypeCompatible( + expectedReturnType, returnType)) { + return method; + } + } + // Second pass: return any overload (first one) + return allOverloads.get(0); + } + + // ==================== INNER CLASS LOOKUP ==================== + + /** + * Find an inner class by name. + */ + public ScriptTypeInfo getInnerClass(String name) { + for (ScriptTypeInfo inner : innerClasses) { + if (inner.getSimpleName().equals(name)) { + return inner; + } + } + return null; + } + + + // Script types are always considered resolved since they're defined in the script + @Override + public boolean isResolved() { + return true; + } + + // Script types don't have a Java class + @Override + public Class getJavaClass() { + return null; + } + + @Override + public String toString() { + return "ScriptTypeInfo{" + scriptClassName + ", " + getKind() + + ", fields=" + fields.size() + ", methods=" + methods.size() + "}"; + } + + // ==================== ERROR HANDLING ==================== + + /** + * Error types for ScriptTypeInfo validation. + */ + public enum ErrorType { + NONE, + MISSING_INTERFACE_METHOD, // Class doesn't implement all interface methods + MISSING_CONSTRUCTOR_MATCH, // Class extends parent but has no matching constructor + UNRESOLVED_PARENT, // Parent class cannot be resolved + UNRESOLVED_INTERFACE // Implemented interface cannot be resolved + } + + /** + * Represents a missing interface method error. + */ + + // Error tracking + private ErrorType errorType = ErrorType.NONE; + private String errorMessage; + private final List missingMethodErrors = new ArrayList<>(); + private final List constructorMismatchErrors = new ArrayList<>(); + + // Error getters + public ErrorType getErrorType() { return errorType; } + public String getErrorMessage() { return errorMessage; } + public boolean hasError() { return errorType != ErrorType.NONE || !missingMethodErrors.isEmpty() || !constructorMismatchErrors.isEmpty(); } + public List getMissingMethodErrors() { return new ArrayList<>(missingMethodErrors); } + public List getConstructorMismatchErrors() { return new ArrayList<>(constructorMismatchErrors); } + + /** + * Set a general error on this type. + */ + public void setError(ErrorType type, String message) { + this.errorType = type; + this.errorMessage = message; + } + + /** + * Add a missing interface method error. + */ + public void addMissingMethodError(TypeInfo interfaceType, String signature) { + missingMethodErrors.add(new MissingMethodError(interfaceType, signature)); + } + + /** + * Add a constructor mismatch error. + */ + public void addConstructorMismatchError(TypeInfo parentType, String parentConstructorSignature) { + constructorMismatchErrors.add(new ConstructorMismatchError(parentType, parentConstructorSignature)); + } + + /** + * Clear all errors on this type. + */ + public void clearErrors() { + errorType = ErrorType.NONE; + errorMessage = null; + missingMethodErrors.clear(); + constructorMismatchErrors.clear(); + } + + public static class MissingMethodError { + private final TypeInfo interfaceType; + private final String signature; + + public MissingMethodError(TypeInfo interfaceType, String signature) { + this.interfaceType = interfaceType; + this.signature = signature; + } + + public String getMessage() { + return "Class must implement method '" + signature + "' from interface " + interfaceType.getSimpleName(); + } + } + + /** + * Represents a constructor mismatch error. + */ + public static class ConstructorMismatchError { + private final TypeInfo parentType; + private final String parentConstructorSignature; + + public ConstructorMismatchError(TypeInfo parentType, String parentConstructorSignature) { + this.parentType = parentType; + this.parentConstructorSignature = parentConstructorSignature; + } + + public String getMessage() { + return "Class extends " + parentType.getSimpleName() + " but has no constructor matching " + parentConstructorSignature; + } + } + + // ==================== VALIDATION ==================== + + /** + * Validate this script type for missing interface methods and constructor matching. + */ + @Override + public void validate() { + // Check that extending class has a matching constructor + if (hasSuperClass()) { + TypeInfo superClass = getSuperClass(); + if (superClass == null || !superClass.isResolved()) { + setError(ErrorType.UNRESOLVED_PARENT, + "Cannot resolve parent class " + getSuperClassName()); + } else { + validateConstructorChain(superClass); + } + } + + // Skip validation for interfaces - they don't implement methods + if (getKind() == Kind.INTERFACE) + return; + + // Check that all interface methods are implemented + if (hasImplementedInterfaces()) { + for (TypeInfo iface : getImplementedInterfaces()) { + if (iface == null || !iface.isResolved()) { + // Mark unresolved interface error + setError(ErrorType.UNRESOLVED_INTERFACE, "Cannot resolve interface"); + continue; + } + validateInterfaceImplementation(iface); + } + } + } + + /** + * Validate that this script type implements all methods from an interface. + */ + private void validateInterfaceImplementation(TypeInfo iface) { + // Handle Java interfaces + Class javaClass = iface.getJavaClass(); + if (javaClass != null && javaClass.isInterface()) { + try { + for (Method javaMethod : javaClass.getMethods()) { + // Skip static and default methods + if (Modifier.isStatic(javaMethod.getModifiers())) + continue; + + // Check if this type has a matching method + String methodName = javaMethod.getName(); + int paramCount = javaMethod.getParameterCount(); + + boolean found = false; + List overloads = getAllMethodOverloads(methodName); + for (MethodInfo method : overloads) { + if (parameterTypesMatch(method, javaMethod)) { + found = true; + break; + } + } + + //Limit to one error at a time to not spam all missing methods + if (!found && missingMethodErrors.isEmpty()) + addMissingMethodError(iface, MethodSignature.asString(javaMethod)); + } + } catch (Exception e) { + } + return; + } + + // Handle script-defined interfaces (ScriptType) + if (iface instanceof ScriptTypeInfo) { + ScriptTypeInfo ifaceType = (ScriptTypeInfo) iface; + + // Check all methods declared in the interface + for (MethodInfo ifaceMethod : ifaceType.getAllMethodsFlat()) { + MethodSignature ifaceSignature = ifaceMethod.getSignature(); + String methodName = ifaceMethod.getName(); + + boolean found = false; + List overloads = getAllMethodOverloads(methodName); + for (MethodInfo method : overloads) { + if (method.getSignature().equals(ifaceSignature)) { + found = true; + break; + } + } + + //Limit to one error at a time to not spam all missing methods + if (!found && missingMethodErrors.isEmpty()) + addMissingMethodError(iface, ifaceSignature.toString()); + } + } + } + + /** + * Validate that this script type has a constructor compatible with its parent class. + * This checks that for each parent constructor, there's a matching constructor in the child. + */ + private void validateConstructorChain(TypeInfo superClass) { + // If no constructors defined in script type, it has an implicit default constructor + // Check if parent has a no-arg constructor + if (!hasConstructors()) { + boolean parentHasNoArg = false; + + if (superClass instanceof ScriptTypeInfo) { + ScriptTypeInfo parentScript = (ScriptTypeInfo) superClass; + if (!parentScript.hasConstructors() || parentScript.findConstructor(0) != null) { + parentHasNoArg = true; + } + } else { + Class javaClass = superClass.getJavaClass(); + if (javaClass != null) { + try { + for (java.lang.reflect.Constructor ctor : javaClass.getConstructors()) { + if (ctor.getParameterCount() == 0) { + parentHasNoArg = true; + break; + } + } + } catch (Exception e) { + // Security error + } + } + } + + if (!parentHasNoArg) { + addConstructorMismatchError(superClass, superClass.getSimpleName()); + } + } + // If script type has constructors, we'd need to check super() calls - that's more complex + // For now, we just validate the implicit default constructor case + } + + /** + * Check if a MethodInfo's parameter types match a Java reflection Method's parameter types. + */ + private boolean parameterTypesMatch(MethodInfo methodInfo, Method javaMethod) { + List params = methodInfo.getParameters(); + Class[] javaParams = javaMethod.getParameterTypes(); + + if (params.size() != javaParams.length) + return false; + + for (int i = 0; i < params.size(); i++) { + TypeInfo paramType = params.get(i).getTypeInfo(); + if (paramType == null) + continue; // Unresolved param, skip check + + Class javaParamClass = javaParams[i]; + String javaParamName = javaParamClass.getName(); + + // Compare type names + if (!paramType.getFullName().equals(javaParamName) && + !paramType.getSimpleName().equals(javaParamClass.getSimpleName())) { + return false; + } + } + + return true; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..70c6b73f4 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeChecker.java @@ -0,0 +1,351 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +/** + * Utility class for checking type compatibility between types. + * Handles primitive widening conversions, boxing/unboxing, and inheritance. + */ +public final class TypeChecker { + + private TypeChecker() {} // Utility class + + /** + * Check if the actual type is compatible with (assignable to) the expected type. + * @param expected The expected/target type + * @param actual The actual/source type + * @return true if actual can be assigned to expected + */ + public static boolean isTypeCompatible(TypeInfo expected, TypeInfo actual) { + if (expected == null) return true; // void can accept anything (shouldn't happen) + if (actual == null) return true; // Can't verify, assume compatible + + // Script method reference placeholder: only compatible with functional interface params. + // Used to help overload selection choose SAM overloads in JavaScript. + if ("__script_method_ref__".equals(actual.getFullName())) { + return expected.isFunctionalInterface(); + } + + // Handle "any" type - universally compatible (JavaScript/TypeScript) + if ("any".equals(expected.getFullName()) || "any".equals(actual.getFullName())) { + return true; + } + + // Handle null literal - null is compatible with any reference type (non-primitive) + if ("".equals(actual.getFullName())) { + Class expectedClass = expected.getJavaClass(); + if (expectedClass != null && !expectedClass.isPrimitive()) { + return true; // null can be assigned to any reference type + } + // null cannot be assigned to primitive types + return false; + } + + String expectedName = expected.getSimpleName(); + String actualName = actual.getSimpleName(); + + if (expectedName == null || actualName == null) return true; + + // Exact match by simple name + if (expectedName.equals(actualName)) return true; + + // Exact match by full name + if (expected.getFullName() != null && actual.getFullName() != null) { + if (expected.getFullName().equals(actual.getFullName())) return true; + } + + // Primitive widening conversions + if (isNumericType(expectedName) && isNumericType(actualName)) { + return canWiden(actualName, expectedName); + } + + // Object type compatibility (check inheritance) + if (expected.getJavaClass() != null && actual.getJavaClass() != null) { + Class expectedClass = expected.getJavaClass(); + Class actualClass = actual.getJavaClass(); + + // Direct assignability + if (expectedClass.isAssignableFrom(actualClass)) { + return true; + } + + // Primitive widening with Class objects + if (isPrimitiveWidening(actualClass, expectedClass)) { + return true; + } + + // Boxing/unboxing compatibility + if (isBoxingCompatible(actualClass, expectedClass)) { + return true; + } + } + + // Allow boxed/unboxed conversions by name + if (isPrimitiveOrWrapper(expectedName) && isPrimitiveOrWrapper(actualName)) { + return getUnboxedName(expectedName).equals(getUnboxedName(actualName)); + } + + return false; + } + + /** + * Check if the type is a numeric type (including wrappers). + */ + public static boolean isNumericType(String typeName) { + switch (typeName) { + case "byte": case "Byte": + case "short": case "Short": + case "int": case "Integer": + case "long": case "Long": + case "float": case "Float": + case "double": case "Double": + case "char": case "Character": + return true; + default: + return false; + } + } + + /** + * Check if a primitive type can be widened to another type. + * Follows Java's widening primitive conversion rules. + */ + public static boolean canWiden(String from, String to) { + int fromRank = getNumericRank(from); + int toRank = getNumericRank(to); + return fromRank <= toRank; + } + + /** + * Get the numeric rank for primitive widening conversion. + * Higher rank can accept lower ranks. + */ + public static int getNumericRank(String typeName) { + switch (getUnboxedName(typeName)) { + case "byte": return 1; + case "short": case "char": return 2; + case "int": return 3; + case "long": return 4; + case "float": return 5; + case "double": return 6; + default: return 0; + } + } + + /** + * Check if a type is a numeric primitive (byte, short, int, long, float, double) + */ + public static boolean isNumericPrimitive(TypeInfo type) { + if (type == null || type.getJavaClass() == null) return false; + Class cls = type.getJavaClass(); + return cls == byte.class || cls == Byte.class || + cls == short.class || cls == Short.class || + cls == int.class || cls == Integer.class || + cls == long.class || cls == Long.class || + cls == float.class || cls == Float.class || + cls == double.class || cls == Double.class; + } + + /** + * Check if a numeric type can be promoted to a target numeric type. + * Follows Java's numeric promotion hierarchy: byte -> short -> int -> long -> float -> double + */ + public static boolean canPromoteNumeric(TypeInfo from, TypeInfo to) { + if (from == null || to == null || from.getJavaClass() == null || to.getJavaClass() == null) return false; + + Class fromClass = from.getJavaClass(); + Class toClass = to.getJavaClass(); + + // Unbox if necessary + if (fromClass == Byte.class) fromClass = byte.class; + if (fromClass == Short.class) fromClass = short.class; + if (fromClass == Integer.class) fromClass = int.class; + if (fromClass == Long.class) fromClass = long.class; + if (fromClass == Float.class) fromClass = float.class; + if (fromClass == Double.class) fromClass = double.class; + + if (toClass == Byte.class) toClass = byte.class; + if (toClass == Short.class) toClass = short.class; + if (toClass == Integer.class) toClass = int.class; + if (toClass == Long.class) toClass = long.class; + if (toClass == Float.class) toClass = float.class; + if (toClass == Double.class) toClass = double.class; + + // Get numeric ranks (higher = wider type) + int fromRank = getNumericRank(fromClass); + int toRank = getNumericRank(toClass); + + return fromRank >= 0 && toRank >= 0 && fromRank <= toRank; + } + + /** + * Get the numeric rank for promotion hierarchy. + * byte=0, short=1, int=2, long=3, float=4, double=5 + */ + public static int getNumericRank(Class cls) { + if (cls == byte.class) return 0; + if (cls == short.class) return 1; + if (cls == int.class) return 2; + if (cls == long.class) return 3; + if (cls == float.class) return 4; + if (cls == double.class) return 5; + return -1; + } + + + /** + * Check if the type name is a primitive or its wrapper type. + */ + public static boolean isPrimitiveOrWrapper(String typeName) { + switch (typeName) { + case "byte": case "Byte": + case "short": case "Short": + case "int": case "Integer": + case "long": case "Long": + case "float": case "Float": + case "double": case "Double": + case "char": case "Character": + case "boolean": case "Boolean": + return true; + default: + return false; + } + } + + /** + * Get the primitive type name from a wrapper type name. + * Returns the input unchanged if already primitive or not a wrapper. + */ + public static String getUnboxedName(String typeName) { + switch (typeName) { + case "Byte": return "byte"; + case "Short": return "short"; + case "Integer": return "int"; + case "Long": return "long"; + case "Float": return "float"; + case "Double": return "double"; + case "Character": return "char"; + case "Boolean": return "boolean"; + default: return typeName; + } + } + + /** + * Get the wrapper type name from a primitive type name. + * Returns the input unchanged if already a wrapper or not a primitive. + */ + public static String getBoxedName(String typeName) { + switch (typeName) { + case "byte": return "Byte"; + case "short": return "Short"; + case "int": return "Integer"; + case "long": return "Long"; + case "float": return "Float"; + case "double": return "Double"; + case "char": return "Character"; + case "boolean": return "Boolean"; + default: return typeName; + } + } + + /** + * Check if the type name represents a void type. + */ + public static boolean isVoidType(String typeName) { + return typeName == null || typeName.equals("void") || typeName.equals("Void"); + } + + /** + * Check if the type represents a void type. + */ + public static boolean isVoidType(TypeInfo type) { + if (type == null) return true; + return isVoidType(type.getSimpleName()); + } + + /** + * Check for primitive widening conversions. + * byte -> short -> int -> long -> float -> double + * char -> int -> long -> float -> double + */ + private static boolean isPrimitiveWidening(Class from, Class to) { + if (!from.isPrimitive() || !to.isPrimitive()) { + return false; + } + + if (from == byte.class) { + return to == short.class || to == int.class || to == long.class || + to == float.class || to == double.class; + } + if (from == short.class || from == char.class) { + return to == int.class || to == long.class || to == float.class || to == double.class; + } + if (from == int.class) { + return to == long.class || to == float.class || to == double.class; + } + if (from == long.class) { + return to == float.class || to == double.class; + } + if (from == float.class) { + return to == double.class; + } + return false; + } + + /** + * Check for boxing/unboxing compatibility. + */ + private static boolean isBoxingCompatible(Class from, Class to) { + if (from.isPrimitive()) { + Class wrapper = getWrapperClassInternal(from); + if (wrapper != null && to.isAssignableFrom(wrapper)) { + return true; + } + } + if (to.isPrimitive()) { + Class wrapper = getWrapperClassInternal(to); + if (wrapper != null && wrapper.isAssignableFrom(from)) { + return true; + } + } + return false; + } + + /** + * Get the wrapper class for a primitive type. + */ + private static Class getWrapperClassInternal(Class primitive) { + if (primitive == boolean.class) return Boolean.class; + if (primitive == byte.class) return Byte.class; + if (primitive == char.class) return Character.class; + if (primitive == short.class) return Short.class; + if (primitive == int.class) return Integer.class; + if (primitive == long.class) return Long.class; + if (primitive == float.class) return Float.class; + if (primitive == double.class) return Double.class; + return null; + } + + public static String[] getJavaKeywords() { + String[] keywords = { + "if", "else", "for", "while", "do", "switch", "case", "break", "continue", + "return", "try", "catch", "finally", "throw", "throws", "new", "this", "super", + "true", "false", "null", "instanceof", "import", "class", "interface", "enum", + "extends", "implements", "public", "private", "protected", "static", "final", + "abstract", "synchronized", "volatile", "transient", "native", "void", + "boolean", "byte", "short", "int", "long", "float", "double", "char" + }; + return keywords; + } + + public static String[] getJaveScriptKeywords() { + 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", + "undefined", "true", "false", "async", "await", "yield", "class", "extends", + "import", "export", "default" + }; + + + return keywords; + } +} 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 new file mode 100644 index 000000000..0c5fcf2e0 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java @@ -0,0 +1,1094 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +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; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSMethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.TypeParamInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; + +import java.lang.reflect.Constructor; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents resolved type information for a class/interface/enum. + * + * This is the unified type system that supports: + * - Java types (via Class reflection) + * - Script-defined types (via ScriptTypeInfo subclass) + * - JavaScript/TypeScript types (via JSTypeInfo bridge) + * + * For JavaScript types, the jsTypeInfo field holds the parsed .d.ts data, + * and methods like hasMethod/getMethodInfo delegate to it. + */ +public class TypeInfo { + + public enum Kind { + CLASS, + INTERFACE, + ENUM, + UNKNOWN + } + + /** + * Singleton constant for the void type. + */ + public static final TypeInfo VOID = fromPrimitive("void"); + + public static final TypeInfo BOOLEAN = fromPrimitive("boolean"); + + public static final TypeInfo STRING = TypeInfo.fromClass(String.class); + + /** + * Singleton constant for the "any" type (used in JavaScript/TypeScript). + * The "any" type is universally compatible - it can be assigned to anything + * and anything can be assigned to it. + */ + public static final TypeInfo ANY = new TypeInfo("any", "any", "", Kind.CLASS, null, true, null); + public static final TypeInfo NUMBER = new TypeInfo("number", "number", "", Kind.CLASS, double.class, true, null); + + + private final String simpleName; // e.g., "List", "ColorType" + private final String fullName; // e.g., "java.util.List", "kamkeel...IOverlay$ColorType" + private final String packageName; // e.g., "java.util", "kamkeel.npcdbc.api.client.overlay" + private final Kind kind; // CLASS, INTERFACE, ENUM + private final Class javaClass; // The actual resolved Java class (null if unresolved or JS type) + private final boolean resolved; // Whether this type was successfully resolved + private final TypeInfo enclosingType; // For inner classes, the outer type (null if top-level) + + // JavaScript/TypeScript type info (for types from .d.ts files) + private final JSTypeInfo jsTypeInfo; // The JS type info (null if Java type) + + // Declared type parameters (generics definition, e.g., "T extends Entity" on interface List) + private final List typeParams = new ArrayList<>(); + + // Applied type arguments (concrete types provided for generics, e.g., in Map) + // These are TypeInfo instances that can themselves be parameterized for nested generics + private final List appliedTypeArgs = new ArrayList<>(); + + // For parameterized types, the raw/base type (e.g., List for List) + // Null for non-parameterized types + private final TypeInfo rawType; + + // Documentation (script-defined types) + private JSDocInfo jsDocInfo; + + // SAM (Single Abstract Method) caching for functional interface detection + private MethodInfo cachedSAM; + private boolean samCacheResolved = false; + + private TypeInfo(String simpleName, String fullName, String packageName, + Kind kind, Class javaClass, boolean resolved, TypeInfo enclosingType) { + this(simpleName, fullName, packageName, kind, javaClass, resolved, enclosingType, null, null, null); + } + + private TypeInfo(String simpleName, String fullName, String packageName, + Kind kind, Class javaClass, boolean resolved, TypeInfo enclosingType, + JSTypeInfo jsTypeInfo) { + this(simpleName, fullName, packageName, kind, javaClass, resolved, enclosingType, jsTypeInfo, null, null); + } + + private TypeInfo(String simpleName, String fullName, String packageName, + Kind kind, Class javaClass, boolean resolved, TypeInfo enclosingType, + JSTypeInfo jsTypeInfo, TypeInfo rawType, List appliedTypeArgs) { + this.simpleName = simpleName; + this.fullName = fullName; + this.packageName = packageName; + this.kind = kind; + this.javaClass = javaClass; + this.resolved = resolved; + this.enclosingType = enclosingType; + this.jsTypeInfo = jsTypeInfo; + this.rawType = rawType; + if (appliedTypeArgs != null) { + this.appliedTypeArgs.addAll(appliedTypeArgs); + } + } + + // Protected constructor for subclasses (like ScriptTypeInfo) + protected TypeInfo(String simpleName, String fullName, String packageName, + Kind kind, Class javaClass, boolean resolved, TypeInfo enclosingType, + @SuppressWarnings("unused") boolean subclass) { + this.simpleName = simpleName; + this.fullName = fullName; + this.packageName = packageName; + this.kind = kind; + this.javaClass = javaClass; + this.resolved = resolved; + this.enclosingType = enclosingType; + this.jsTypeInfo = null; + this.rawType = null; + } + + // Factory methods + public static TypeInfo resolved(String simpleName, String fullName, String packageName, + Kind kind, Class javaClass) { + return new TypeInfo(simpleName, fullName, packageName, kind, javaClass, true, null); + } + + public static TypeInfo resolvedInner(String simpleName, String fullName, String packageName, + Kind kind, Class javaClass, TypeInfo enclosing) { + return new TypeInfo(simpleName, fullName, packageName, kind, javaClass, true, enclosing); + } + + public static TypeInfo unresolved(String simpleName, String fullPath) { + int lastDot = fullPath.lastIndexOf('.'); + String pkg = lastDot > 0 ? fullPath.substring(0, lastDot) : ""; + return new TypeInfo(simpleName, fullPath, pkg, Kind.UNKNOWN, null, false, null); + } + + public static TypeInfo fromClass(Class clazz) { + if (clazz == null) return null; + + Kind kind; + if (clazz.isInterface()) { + kind = Kind.INTERFACE; + } else if (clazz.isEnum()) { + kind = Kind.ENUM; + } else { + kind = Kind.CLASS; + } + + String fullName = clazz.getName(); + String simpleName = clazz.getSimpleName(); + Package pkg = clazz.getPackage(); + String packageName = ""; + if (pkg != null) { + packageName = pkg.getName(); + } else if (!fullName.equals(simpleName)) { + int lastDot = fullName.lastIndexOf('.'); + if (lastDot > 0) { + packageName = fullName.substring(0, lastDot); + } + } + + TypeInfo enclosing = null; + if (clazz.getEnclosingClass() != null) { + enclosing = fromClass(clazz.getEnclosingClass()); + } + + return new TypeInfo(simpleName, fullName, packageName, kind, clazz, true, enclosing); + } + + /** + * Create a TypeInfo from a generic java.lang.reflect.Type. + * This preserves generic type arguments from reflection APIs like + * Method.getGenericReturnType() and Field.getGenericType(). + * + * Examples: + * - List -> TypeInfo(List) with appliedTypeArgs=[TypeInfo(String)] + * - Map -> TypeInfo(Map) with appliedTypeArgs=[TypeInfo(String), TypeInfo(Integer)] + * - T (type variable) -> TypeInfo representing the type variable name + * + * @param type The generic type from reflection + * @return A TypeInfo preserving the generic structure, or null if type is null + */ + public static TypeInfo fromGenericType(Type type) { + if (type == null) return null; + + if (type instanceof Class) { + // Plain class - no generic info + return fromClass((Class) type); + } + + if (type instanceof ParameterizedType) { + // Generic type like List + ParameterizedType paramType = (ParameterizedType) type; + Type rawType = paramType.getRawType(); + + if (!(rawType instanceof Class)) { + // Edge case - should not happen normally + return rawType != null ? fromGenericType(rawType) : null; + } + + // Get the raw type as TypeInfo + TypeInfo rawTypeInfo = fromClass((Class) rawType); + + // Recursively convert type arguments + Type[] typeArgs = paramType.getActualTypeArguments(); + List appliedArgs = new ArrayList<>(typeArgs.length); + for (Type arg : typeArgs) { + TypeInfo argInfo = fromGenericType(arg); + if (argInfo != null) { + appliedArgs.add(argInfo); + } + } + + // Return a parameterized TypeInfo + if (!appliedArgs.isEmpty()) { + return rawTypeInfo.parameterize(appliedArgs); + } + return rawTypeInfo; + } + + if (type instanceof GenericArrayType) { + // Array of generic type, e.g. T[] or List[] + GenericArrayType arrayType = (GenericArrayType) type; + TypeInfo elementType = fromGenericType(arrayType.getGenericComponentType()); + if (elementType != null) { + return arrayOf(elementType); + } + return fromClass(Object[].class); + } + + if (type instanceof TypeVariable) { + // Type variable like T, E, K, V + TypeVariable typeVar = (TypeVariable) type; + String varName = typeVar.getName(); + // Create an unresolved type representing the type variable + // The substitution system will handle replacing this with the actual type + return TypeInfo.unresolved(varName, varName); + } + + if (type instanceof WildcardType) { + // Wildcard like ? or ? extends Number or ? super Integer + WildcardType wildcard = (WildcardType) type; + Type[] upperBounds = wildcard.getUpperBounds(); + + // Use the upper bound if available (most useful for ? extends T) + if (upperBounds.length > 0 && upperBounds[0] != Object.class) { + return fromGenericType(upperBounds[0]); + } + + // For ? super T or plain ?, just use Object + return fromClass(Object.class); + } + + // Unknown type - shouldn't happen in practice + return null; + } + + /** + * Create a TypeInfo for a primitive type. + */ + public static TypeInfo fromPrimitive(String typeName) { + Class primitiveClass = null; + switch (typeName) { + case "boolean": primitiveClass = boolean.class; break; + case "byte": primitiveClass = byte.class; break; + case "char": primitiveClass = char.class; break; + case "short": primitiveClass = short.class; break; + case "int": primitiveClass = int.class; break; + case "long": primitiveClass = long.class; break; + case "float": primitiveClass = float.class; break; + case "double": primitiveClass = double.class; break; + case "void": primitiveClass = void.class; break; + } + return new TypeInfo(typeName, typeName, "", Kind.CLASS, primitiveClass, true, null); + } + + /** + * Create a TypeInfo from a JavaScript/TypeScript type. + * This bridges JS types parsed from .d.ts files into the unified type system. + * + * @param jsType The parsed JS type info + * @return A TypeInfo wrapping the JS type + */ + public static TypeInfo fromJSTypeInfo(JSTypeInfo jsType) { + if (jsType == null) return null; + + String simpleName = jsType.getSimpleName(); + String fullName = jsType.getFullName(); + String namespace = jsType.getNamespace(); + + // JS interfaces are always Kind.INTERFACE + return new TypeInfo(simpleName, fullName, namespace != null ? namespace : "", + Kind.INTERFACE, null, true, null, jsType); + } + + /** + * Create an array type wrapping the given element type. + * + * @param elementType The type of elements in the array + * @return A TypeInfo representing the array type + */ + public static TypeInfo arrayOf(TypeInfo elementType) { + if (elementType == null) { + return fromClass(Object[].class); + } + + // Preserve generic display in arrays (e.g., List[]) and keep JS namespace display. + String simpleName = elementType.getDisplayName() + "[]"; + String fullName = elementType.getDisplayNameFull() + "[]"; + String pkg = elementType.getPackageName(); + + // Try to get the actual array class if we have a Java class + Class arrayClass = null; + if (elementType.getJavaClass() != null) { + try { + arrayClass = java.lang.reflect.Array.newInstance(elementType.getJavaClass(), 0).getClass(); + } catch (Exception e) { + // Fallback to Object array if we can't create the specific array type + } + } + + return new TypeInfo(simpleName, fullName, pkg, Kind.CLASS, arrayClass, true, null); + } + + /** + * Check if this is a JavaScript type (backed by JSTypeInfo). + */ + public boolean isJSType() { + return jsTypeInfo != null; + } + + /** + * Get the underlying JSTypeInfo if this is a JS type. + */ + public JSTypeInfo getJSTypeInfo() { + return jsTypeInfo; + } + + public JSDocInfo getJSDocInfo() { + if (jsDocInfo != null) { + return jsDocInfo; + } + return jsTypeInfo != null ? jsTypeInfo.getJsDocInfo() : null; + } + + public void setJSDocInfo(JSDocInfo jsDocInfo) { this.jsDocInfo = jsDocInfo; } + + // Getters + public String getSimpleName() { return simpleName; } + public String getFullName() { return fullName; } + public String getPackageName() { return packageName; } + public Kind getKind() { return kind; } + public Class getJavaClass() { return javaClass; } + public boolean isResolved() { return resolved; } + public TypeInfo getEnclosingType() { return enclosingType; } + public boolean isInnerClass() { return enclosingType != null; } + public boolean isInterface() {return kind == Kind.INTERFACE;} + public boolean isEnum() {return kind == Kind.ENUM;} + public boolean isPrimitive() {return javaClass != null && javaClass.isPrimitive();} + + // ==================== Applied Type Arguments (Parameterized Types) ==================== + + /** + * Check if this type is parameterized (has applied type arguments). + * For example, List is parameterized, but List is not. + */ + public boolean isParameterized() { + return !appliedTypeArgs.isEmpty(); + } + + /** + * Get the raw/base type if this is parameterized. + * For List, returns the TypeInfo for List. + * For non-parameterized types, returns this. + */ + public TypeInfo getRawType() { + return rawType != null ? rawType : this; + } + + /** + * Get the applied type arguments. + * For List, returns [TypeInfo(String)]. + * For Map, returns [TypeInfo(String), TypeInfo(Integer)]. + * For nested generics like List>, the Map TypeInfo itself has appliedTypeArgs. + */ + public List getAppliedTypeArgs() { + return appliedTypeArgs; + } + + /** + * Create a parameterized version of this type with the given type arguments. + * For example, calling parameterize([TypeInfo(String)]) on List returns List. + * + * @param typeArgs The type arguments to apply + * @return A new parameterized TypeInfo + */ + public TypeInfo parameterize(List typeArgs) { + if (typeArgs == null || typeArgs.isEmpty()) { + return this; + } + + // Keep base names stable for logic (constructor names, lookups, etc.). + // Generic arguments are tracked via appliedTypeArgs and rendered via getDisplayName*(). + TypeInfo raw = getRawType(); + return new TypeInfo(raw.simpleName, raw.fullName, raw.packageName, raw.kind, raw.javaClass, + raw.resolved, raw.enclosingType, raw.jsTypeInfo, raw, typeArgs); + } + + /** + * Create a parameterized version of this type with a single type argument. + */ + public TypeInfo parameterize(TypeInfo typeArg) { + if (typeArg == null) { + return this; + } + List args = new ArrayList<>(); + args.add(typeArg); + return parameterize(args); + } + + /** + * Build the generic type arguments string like "". + * @param typeArgs The type arguments + * @param useFullNames Whether to use full names (for fullName) or simple names (for simpleName) + */ + private static String buildTypeArgsString(List typeArgs, boolean useFullNames) { + if (typeArgs == null || typeArgs.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder("<"); + for (int i = 0; i < typeArgs.size(); i++) { + if (i > 0) sb.append(", "); + TypeInfo arg = typeArgs.get(i); + sb.append(useFullNames ? arg.getDisplayNameFull() : arg.getDisplayNameSimple()); + } + sb.append(">"); + return sb.toString(); + } + + /** + * Get the canonical display name for this type, including generic arguments. + * This is the preferred method for UI display (hover, autocomplete, etc.). + * + * For Java types: uses simple name + generics (e.g., "List") + * For JS types: uses full name + generics (e.g., "IPlayerEvent.InteractEvent") + * For arrays: includes "[]" suffix + * For nested generics: recursively builds (e.g., "List>") + */ + public String getDisplayName() { + // Use full name for JS types, simple name for Java + return isJSType() ? getDisplayNameFull() : getDisplayNameSimple(); + } + + /** + * Get display name using simple names for all types. + * Example: "Map" instead of "java.util.Map" + */ + public String getDisplayNameSimple() { + if (appliedTypeArgs.isEmpty()) { + return simpleName; + } + + String baseName = rawType != null ? rawType.getSimpleName() : simpleName; + return baseName + buildTypeArgsString(appliedTypeArgs, false); + } + + /** + * Get display name using full qualified names. + * Example: "java.util.Map" + */ + public String getDisplayNameFull() { + if (appliedTypeArgs.isEmpty()) { + return fullName; + } + // For full display, use the raw type's full name + the args with their display names + String baseName = rawType != null ? rawType.getFullName() : fullName; + return baseName + buildTypeArgsString(appliedTypeArgs, true); + } + + /** + * Returns true if this TypeInfo represents a Class reference (not an instance). + * For example, Java.type("java.io.File") returns a Class reference. + * Override in ClassTypeInfo to return true. + */ + public boolean isClassReference() { return false; } + + /** + * Get the appropriate TokenType for highlighting this type. + */ + public TokenType getTokenType() { + if (!resolved) + return TokenType.UNDEFINED_VAR; + if(isPrimitive()) + return TokenType.KEYWORD; + + switch (kind) { + case INTERFACE: + return TokenType.INTERFACE_DECL; + case ENUM: + return TokenType.ENUM_DECL; + case CLASS: + default: + return TokenType.IMPORTED_CLASS; + } + } + + /** + * Check if this type has a method with the given name. + */ + public boolean hasMethod(String methodName) { + // Check JS type first + if (jsTypeInfo != null) { + return jsTypeInfo.hasMethod(methodName); + } + + if (javaClass == null) return false; + try { + for (java.lang.reflect.Method m : javaClass.getMethods()) { + if (m.getName().equals(methodName)) { + return true; + } + } + } catch (Exception e) { + // Security or linkage error + } + return false; + } + + /** + * Check if this type has a method with the given name and parameter count. + */ + public boolean hasMethod(String methodName, int paramCount) { + // Check JS type first + if (jsTypeInfo != null) { + List overloads = jsTypeInfo.getMethodOverloads(methodName); + for (JSMethodInfo m : overloads) { + if (m.getParameterCount() == paramCount) { + return true; + } + } + return false; + } + + if (javaClass == null) return false; + try { + for (java.lang.reflect.Method m : javaClass.getMethods()) { + if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) { + return true; + } + } + } catch (Exception e) { + // Security or linkage error + } + return false; + } + + /** + * Check if this type has constructors. + * Override in ScriptTypeInfo for script-defined types. + */ + public boolean hasConstructors() { + if (javaClass == null) return false; + try { + return javaClass.getConstructors().length > 0; + } catch (Exception e) { + // Security or linkage error + return false; + } + } + + /** + * Get the constructors for this type. + * Override in ScriptTypeInfo for script-defined types. + */ + public List getConstructors() { + List result = new ArrayList<>(); + if (javaClass == null) return result; + + try { + java.lang.reflect.Constructor[] constructors = javaClass.getConstructors(); + for (java.lang.reflect.Constructor ctor : constructors) { + result.add(MethodInfo.fromReflectionConstructor(ctor, this)); + } + } catch (Exception e) { + // Security or linkage error + } + return result; + } + + /** + * Find the best matching constructor for the given argument count. + * Override in ScriptTypeInfo for script-defined types. + */ + public MethodInfo findConstructor(int argCount) { + if (javaClass == null) return null; + + try { + Constructor[] constructors = javaClass.getConstructors(); + for (Constructor ctor : constructors) { + if (ctor.getParameterCount() == argCount) { + return MethodInfo.fromReflectionConstructor(ctor, this); + } + } + } catch (Exception e) { + // Security or linkage error + } + return null; + } + + public MethodInfo findConstructor(TypeInfo[] argTypes) { + if (javaClass == null) return null; + + try { + Constructor[] constructors = javaClass.getConstructors(); + for (Constructor ctor : constructors) { + if (ctor.getParameterCount() == argTypes.length) { + Class[] paramTypes = ctor.getParameterTypes(); + boolean match = true; + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramTypeInfo = TypeInfo.fromClass(paramTypes[i]); + if (!TypeChecker.isTypeCompatible(paramTypeInfo, argTypes[i])) { + match = false; + break; + } + } + if (match) { + return MethodInfo.fromReflectionConstructor(ctor, this); + } + } + } + } catch (Exception e) { + // Security or linkage error + } + return null; + } + + /** + * Check if this type has a field with the given name. + */ + public boolean hasField(String fieldName) { + // Check JS type first + if (jsTypeInfo != null) { + return jsTypeInfo.hasField(fieldName); + } + + if (javaClass == null) return false; + try { + for (java.lang.reflect.Field f : javaClass.getFields()) { + if (f.getName().equals(fieldName)) { + return true; + } + } + } catch (Exception e) { + // Security or linkage error + } + return false; + } + + public boolean hasEnumConstant(String constantName) { + if (javaClass == null || !javaClass.isEnum()) return false; + try { + Object[] constants = javaClass.getEnumConstants(); + for (Object constant : constants) { + if (constant.toString().equals(constantName)) + return true; + } + } catch (Exception e) { + // Security or linkage error + } + return false; + } + + /** + * Get an enum constant by name. + */ + public EnumConstantInfo getEnumConstant(String constantName) { + if (javaClass == null || !javaClass.isEnum()) return null; + try { + Object[] constants = javaClass.getEnumConstants(); + for (Object constant : constants) { + if (constant.toString().equals(constantName)) + return EnumConstantInfo.fromReflection(constantName, this, null); + } + } catch (Exception e) { + // Security or linkage error + } + return null; + } + + /** + * Get MethodInfo for a method by name. Returns null if not found. + * Creates a synthetic MethodInfo based on reflection data or JS type data. + */ + public MethodInfo getMethodInfo(String methodName) { + // Check JS type first + if (jsTypeInfo != null) { + JSMethodInfo jsMethod = jsTypeInfo.getMethod(methodName); + if (jsMethod != null) { + return MethodInfo.fromJSMethod(jsMethod, this); + } + return null; + } + + if (javaClass == null) return null; + try { + for (java.lang.reflect.Method m : javaClass.getMethods()) { + if (m.getName().equals(methodName)) { + // Create a synthetic MethodInfo from reflection + return MethodInfo.fromReflection(m, this); + } + } + } catch (Exception e) { + // Security or linkage error + } + return null; + } + + /** + * Get all MethodInfo overloads for a method by name. + * Returns an empty list if not found. + */ + public java.util.List getAllMethodOverloads(String methodName) { + java.util.List overloads = new java.util.ArrayList<>(); + + // Check JS type first + if (jsTypeInfo != null) { + for (JSMethodInfo jsMethod : jsTypeInfo.getMethodOverloads(methodName)) { + // Type parameter resolution now happens inside fromJSMethod using this TypeInfo + overloads.add(MethodInfo.fromJSMethod(jsMethod, this)); + } + return overloads; + } + + if (javaClass == null) return overloads; + try { + for (java.lang.reflect.Method m : javaClass.getMethods()) { + if (m.getName().equals(methodName)) { + overloads.add(MethodInfo.fromReflection(m, this)); + } + } + } catch (Exception e) { + // Security or linkage error + } + return overloads; + } + + /** + * Find the best matching method overload considering return type. + * First tries to find a match with compatible return type, then falls back to any match. + * + * @param methodName The name of the method + * @param expectedReturnType The expected return type (can be null) + * @return The best matching MethodInfo, or null if not found + */ + public MethodInfo getBestMethodOverload(String methodName, TypeInfo expectedReturnType) { + java.util.List overloads = getAllMethodOverloads(methodName); + if (overloads.isEmpty()) return null; + + // If no expected return type, return first overload + if (expectedReturnType == null) { + return overloads.get(0); + } + + // First pass: look for return type compatible overload + for (MethodInfo method : overloads) { + TypeInfo returnType = method.getReturnType(); + if (returnType != null && TypeChecker.isTypeCompatible(expectedReturnType, returnType)) { + return method; + } + } + // Second pass: return any overload (first one) + return overloads.get(0); + } + + /** + * Find the best matching method overload based on argument types. + * Uses Java's method resolution rules: exact match, then numeric promotion, then widening conversion, then autoboxing. + * + * @param methodName The name of the method + * @param argTypes The types of the arguments being passed + * @return The best matching MethodInfo, or null if not found + */ + public MethodInfo getBestMethodOverload(String methodName, TypeInfo[] argTypes) { + java.util.List overloads = getAllMethodOverloads(methodName); + if (overloads.isEmpty()) return null; + return OverloadSelector.selectBestOverload(overloads, argTypes); + } + + /** + * Get FieldInfo for a field by name. Returns null if not found. + * Creates a synthetic FieldInfo based on reflection data or JS type data. + */ + public FieldInfo getFieldInfo(String fieldName) { + // Check JS type first + if (jsTypeInfo != null) { + JSFieldInfo jsField = jsTypeInfo.getField(fieldName); + if (jsField != null) { + return FieldInfo.fromJSField(jsField, this); + } + return null; + } + + if (javaClass == null) return null; + try { + for (java.lang.reflect.Field f : javaClass.getFields()) { + if (f.getName().equals(fieldName)) { + // Create a synthetic FieldInfo from reflection + return FieldInfo.fromReflection(f, this); + } + } + } catch (Exception e) { + // Security or linkage error + } + return null; + } + + /** + * Validate this type. Default implementation does nothing (for Java types). + * Override in ScriptTypeInfo to validate script-defined types. + */ + public void validate() { + // Default: no validation for Java types + } + + // ==================== Functional Interface (SAM) Detection ==================== + + /** + * Determine whether this type is a functional interface and return its single abstract method (SAM). + * + * For Java reflection types (javaClass != null): + * - Must be an interface + * - Collects all public methods from javaClass.getMethods() + * - Filters out: static methods, default methods, and Object methods + * - If exactly one abstract instance method remains, returns it as a MethodInfo + * + * For script-defined interfaces (ScriptTypeInfo with kind == INTERFACE): + * - Identifies abstract methods (methods without body) + * - If exactly one exists, returns it as a MethodInfo + * + * @return MethodInfo for the single abstract method, or null if not a functional interface + */ + public MethodInfo getSingleAbstractMethod() { + // Return cached result if already resolved + if (samCacheResolved) { + return cachedSAM; + } + + // Mark as resolved to prevent re-computation + samCacheResolved = true; + + try { + // Handle Java reflection types + if (javaClass != null) { + // Only interfaces can be functional interfaces (for simplicity) + if (!javaClass.isInterface()) { + cachedSAM = null; + return null; + } + + // Collect all public methods + java.lang.reflect.Method[] methods = javaClass.getMethods(); + java.lang.reflect.Method singleAbstractMethod = null; + + for (java.lang.reflect.Method method : methods) { + int modifiers = method.getModifiers(); + + // Skip static methods + if (java.lang.reflect.Modifier.isStatic(modifiers)) { + continue; + } + + // Skip default methods (Java 8+) + if (method.isDefault()) { + continue; + } + + // Skip methods inherited from Object + String methodName = method.getName(); + if (isObjectMethod(methodName, method.getParameterCount())) { + continue; + } + + // This is an abstract instance method + if (singleAbstractMethod != null) { + // More than one abstract method - not a functional interface + cachedSAM = null; + return null; + } + + singleAbstractMethod = method; + } + + // If exactly one abstract method found, create MethodInfo with generic substitution + if (singleAbstractMethod != null) { + cachedSAM = MethodInfo.fromReflection(singleAbstractMethod, this); + return cachedSAM; + } + + cachedSAM = null; + return null; + } + + // Handle script-defined interfaces + if (this instanceof ScriptTypeInfo) { + ScriptTypeInfo scriptType = (ScriptTypeInfo) this; + + // Only check interfaces + if (scriptType.getKind() != Kind.INTERFACE) { + cachedSAM = null; + return null; + } + + // Find all abstract methods (methods without body) + // For script-defined interfaces, all declared methods are implicitly abstract + java.util.List allMethods = scriptType.getAllMethodsFlat(); + + if (allMethods.size() == 1) { + // Exactly one method - it's the SAM + cachedSAM = allMethods.get(0); + return cachedSAM; + } else if (allMethods.size() > 1) { + // More than one method - not a functional interface + cachedSAM = null; + return null; + } + + cachedSAM = null; + return null; + } + + // Not a functional interface + cachedSAM = null; + return null; + + } catch (Exception e) { + // Any reflection or security error - treat as not a functional interface + cachedSAM = null; + return null; + } + } + + /** + * Check if this type is a functional interface (has a single abstract method). + * + * @return true if this is a functional interface, false otherwise + */ + public boolean isFunctionalInterface() { + return getSingleAbstractMethod() != null; + } + + /** + * Helper method to check if a method is inherited from java.lang.Object. + * These methods don't count toward the SAM requirement. + * + * @param methodName The name of the method + * @param paramCount The number of parameters + * @return true if this is an Object method + */ + private boolean isObjectMethod(String methodName, int paramCount) { + switch (methodName) { + case "equals": + return paramCount == 1; + case "hashCode": + case "toString": + case "getClass": + case "notify": + case "notifyAll": + return paramCount == 0; + case "wait": + // wait() has overloads with 0, 1, and 2 parameters + return paramCount == 0 || paramCount == 1 || paramCount == 2; + default: + return false; + } + } + + // ==================== Type Parameter Methods ==================== + + /** + * Add a type parameter to this type. + * Used during parsing/construction of types with generics. + */ + public void addTypeParam(TypeParamInfo param) { + // If this is a JS type, delegate to JSTypeInfo + if (jsTypeInfo != null) { + jsTypeInfo.addTypeParam(param); + } else { + typeParams.add(param); + } + } + + /** + * Get all type parameters for this type. + * @return List of type parameters (empty if none) + */ + public List getTypeParams() { + // If this is a JS type, delegate to JSTypeInfo + if (jsTypeInfo != null) { + return jsTypeInfo.getTypeParams(); + } + + // For Java reflection types, lazily expose declared generic parameters (e.g., List, Map) + if (javaClass != null && typeParams.isEmpty()) { + try { + TypeVariable[] vars = javaClass.getTypeParameters(); + for (TypeVariable v : vars) { + if (v == null) continue; + String n = v.getName(); + if (n == null || n.isEmpty()) continue; + typeParams.add(new TypeParamInfo(n, null, null)); + } + } catch (Exception ignored) { + // Best-effort only; leave typeParams empty. + } + } + return typeParams; + } + + /** + * Resolve all type parameters for this type. + * Called during Phase 2 after all types are loaded into the registry. + */ + public void resolveTypeParameters() { + // If this is a JS type, delegate to JSTypeInfo + if (jsTypeInfo != null) { + jsTypeInfo.resolveTypeParameters(); + } else { + for (TypeParamInfo param : typeParams) { + param.resolveBoundType(); + } + } + } + + /** + * Get the type parameter info for a given parameter name (e.g., "T"). + * @return TypeParamInfo or null if not found + */ + public TypeParamInfo getTypeParam(String name) { + // If this is a JS type, delegate to JSTypeInfo + if (jsTypeInfo != null) { + return jsTypeInfo.getTypeParam(name); + } + + for (TypeParamInfo param : typeParams) { + if (param.getName().equals(name)) { + return param; + } + } + return null; + } + + /** + * Resolves a type parameter to its bound TypeInfo. + * For example, if this type has "T extends EntityPlayerMP", resolveTypeParamToTypeInfo("T") returns the TypeInfo for EntityPlayerMP. + * If no type parameter is found with that name, returns null. + */ + public TypeInfo resolveTypeParamToTypeInfo(String typeName) { + TypeParamInfo param = getTypeParam(typeName); + if (param != null) { + return param.getBoundTypeInfo(); + } + return null; + } + + @Override + public String toString() { + return "TypeInfo{" + fullName + ", " + kind + ", resolved=" + resolved + "}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TypeInfo typeInfo = (TypeInfo) o; + return fullName.equals(typeInfo.fullName); + } + + @Override + public int hashCode() { + return fullName.hashCode(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeResolver.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeResolver.java new file mode 100644 index 000000000..56f74dffd --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeResolver.java @@ -0,0 +1,949 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.PackageFinder; +import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; +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.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; +import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.SyntheticType; + +import java.util.*; + +/** + * Unified type resolver for both Java and JavaScript/TypeScript types. + * Serves as the single front-end for all type resolution needs. + * + * For Java: Uses reflection and import resolution + * For JavaScript: Delegates to JSTypeRegistry for .d.ts defined types + * + * This is the ONLY class that should be used for type resolution. + */ +public class TypeResolver { + + // Cache: fully-qualified class name -> TypeInfo + private final Map typeCache = new HashMap<>(); + + // Cache: validated package paths + private final Set validPackages = new HashSet<>(); + + // JavaScript type registry (lazily initialized) + private JSTypeRegistry jsTypeRegistry; + + // Synthetic type registry (e.g., Nashorn built-ins) + private final Map syntheticTypes = new LinkedHashMap<>(); + + // Auto-imported java.lang classes + public static final Set JAVA_LANG_CLASSES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "Object", "String", "Class", "System", "Math", "Integer", "Double", "Float", "Long", "Short", "Byte", + "Character", "Boolean", "Number", "Void", "Thread", "Runnable", "Exception", "RuntimeException", + "Error", "Throwable", "StringBuilder", "StringBuffer", "Enum", "Comparable", "Iterable", + "CharSequence", "Cloneable", "Process", "ProcessBuilder", "Runtime", "SecurityManager", + "ClassLoader", "Package", "ArithmeticException", "ArrayIndexOutOfBoundsException", + "ClassCastException", "IllegalArgumentException", "IllegalStateException", + "IndexOutOfBoundsException", "NullPointerException", "NumberFormatException", + "UnsupportedOperationException", "AssertionError", "OutOfMemoryError", "StackOverflowError" + ))); + + // JavaScript primitive type mappings + private static final Map JS_PRIMITIVE_TO_JAVA = new HashMap<>(); + static { + JS_PRIMITIVE_TO_JAVA.put("string", "String"); + JS_PRIMITIVE_TO_JAVA.put("number", "double"); + JS_PRIMITIVE_TO_JAVA.put("boolean", "boolean"); + JS_PRIMITIVE_TO_JAVA.put("any", "Object"); + JS_PRIMITIVE_TO_JAVA.put("void", "void"); + JS_PRIMITIVE_TO_JAVA.put("null", "null"); + JS_PRIMITIVE_TO_JAVA.put("undefined", "void"); + JS_PRIMITIVE_TO_JAVA.put("object", "Object"); + } + + // Singleton instance for global caching (optional - can also use per-document instances) + private static TypeResolver instance; + + public static TypeResolver getInstance() { + if (instance == null) { + instance = new TypeResolver(); + } + return instance; + } + + public TypeResolver() { + // Pre-register common packages + validPackages.add("java"); + validPackages.add("java.lang"); + validPackages.add("java.util"); + validPackages.add("java.io"); + + // Initialize built-in synthetic types + initializeSyntheticTypes(); + } + + /** + * Initialize built-in synthetic types (e.g., Nashorn built-ins). + */ + private void initializeSyntheticTypes() { + // Register all Nashorn built-in types (Java, etc.) + for (SyntheticType type : NashornBuiltins.getInstance().getAllBuiltinTypes()) { + syntheticTypes.put(type.getName(), type); + } + + // Register all Nashorn global functions (print, load, etc.) + Map nashornGlobals = NashornBuiltins.getInstance().getAllGlobalFunctions(); + syntheticTypes.putAll(nashornGlobals); + } + + // ==================== SYNTHETIC TYPE REGISTRY ==================== + + /** + * Register a synthetic type for use in scripts. + * @param name The type name (e.g., "Java", "MyCustomType") + * @param type The synthetic type definition + */ + public void registerSyntheticType(String name, SyntheticType type) { + syntheticTypes.put(name, type); + } + + /** + * Check if a name is a known synthetic type. + * @param name The type name to check + * @return true if the name is a registered synthetic type + */ + public boolean isSyntheticType(String name) { + return syntheticTypes.containsKey(name); + } + + /** + * Get a synthetic type by name. + * @param name The type name + * @return The synthetic type, or null if not found + */ + public SyntheticType getSyntheticType(String name) { + return syntheticTypes.get(name); + } + + /** + * Get all registered synthetic types. + * @return Collection of all synthetic types + */ + public Collection getAllSyntheticTypes() { + return syntheticTypes.values(); + } + + // ==================== JS TYPE REGISTRY ACCESS ==================== + + /** + * Get the JS type registry, initializing it if needed. + */ + public JSTypeRegistry getJSTypeRegistry() { + if (jsTypeRegistry == null) { + jsTypeRegistry = JSTypeRegistry.getInstance(); + if (!jsTypeRegistry.isInitialized()) { + jsTypeRegistry.initializeFromResources(); + } + } + return jsTypeRegistry; + } + + /** + * Check if a function name is a known JS hook. + */ + public boolean isJSHook(String functionName) { + return getJSTypeRegistry().isHook(functionName); + } + + /** + * Get the parameter type for a JS hook function. + */ + public String getJSHookParameterType(String functionName) { + return getJSTypeRegistry().getHookParameterType(functionName); + } + + /** + * Get hook signatures for a JS function. + */ + public List getJSHookSignatures(String functionName) { + return getJSTypeRegistry().getHookSignatures(functionName); + } + + // ==================== CONTEXT-AWARE HOOK METHODS ==================== + + /** + * Check if a function name is a known JS hook in a specific script context. + * Falls back to GLOBAL context if not found in the specified context. + * + * @param namespace The event interface namespace (e.g., "INpcEvent", "IPlayerEvent") + * @param functionName The function name to check + * @return true if it's a known hook in this namespace + */ + public boolean isJSHook(String namespace, String functionName) { + return getJSTypeRegistry().isHook(namespace, functionName); + } + + /** + * Get the parameter type for a JS hook function in a specific namespace. + * Falls back to "Global" namespace if not found in the specified namespace. + * + * @param namespace The event interface namespace (e.g., "INpcEvent", "IPlayerEvent") + * @param functionName The hook function name + * @return The parameter type, or null if not a hook + */ + public String getJSHookParameterType(String namespace, String functionName) { + return getJSTypeRegistry().getHookParameterType(namespace, functionName); + } + + /** + * Get hook signatures for a JS function in a specific namespace. + * Falls back to "Global" namespace if not found in the specified namespace. + * + * @param namespace The event interface namespace (e.g., "INpcEvent", "IPlayerEvent") + * @param functionName The hook function name + * @return List of hook signatures + */ + public List getJSHookSignatures(String namespace, String functionName) { + return getJSTypeRegistry().getHookSignatures(namespace, functionName); + } + + /** + * Get all hook names available in a specific namespace. + * + * @param namespace The event interface namespace + * @return Set of hook function names + */ + public Set getJSHookNames(String namespace) { + return getJSTypeRegistry().getHookNames(namespace); + } + + // ==================== MULTI-NAMESPACE HOOK METHODS ==================== + + /** + * Check if a function name is a known JS hook in any of the provided namespaces. + * Checks namespaces in order, returning true on first match. + * Falls back to GLOBAL namespace if not found in any specified namespace. + * + * @param namespaces The list of event interface namespaces to search (e.g., ["IPlayerEvent", "IAnimationEvent"]) + * @param functionName The function name to check + * @return true if it's a known hook in any of the namespaces + */ + public boolean isJSHook(List namespaces, String functionName) { + return getJSTypeRegistry().isHook(namespaces, functionName); + } + + /** + * Get the parameter type for a JS hook function, searching multiple namespaces. + * Checks namespaces in order, returning the first match found. + * Falls back to "Global" namespace if not found in any specified namespace. + * + * @param namespaces The list of event interface namespaces to search + * @param functionName The hook function name + * @return The parameter type, or null if not a hook + */ + public String getJSHookParameterType(List namespaces, String functionName) { + return getJSTypeRegistry().getHookParameterType(namespaces, functionName); + } + + /** + * Get hook signatures for a JS function, searching multiple namespaces. + * Checks namespaces in order, returning signatures from the first matching namespace. + * Falls back to "Global" namespace if not found in any specified namespace. + * + * @param namespaces The list of event interface namespaces to search + * @param functionName The hook function name + * @return List of hook signatures + */ + public List getJSHookSignatures(List namespaces, String functionName) { + return getJSTypeRegistry().getHookSignatures(namespaces, functionName); + } + + /** + * Get all hook names available in any of the provided namespaces. + * Combines hook names from all specified namespaces. + * + * @param namespaces The list of event interface namespaces + * @return Combined set of hook function names from all namespaces + */ + public Set getJSHookNames(List namespaces) { + return getJSTypeRegistry().getHookNames(namespaces); + } + + // ==================== CACHE MANAGEMENT ==================== + + /** + * Clear all caches. Call when imports change significantly. + */ + public void clearCache() { + typeCache.clear(); + validPackages.clear(); + // Re-add common packages + validPackages.add("java"); + validPackages.add("java.lang"); + validPackages.add("java.util"); + validPackages.add("java.io"); + } + + // ==================== UNIFIED TYPE RESOLUTION ==================== + + /** + * General-purpose type resolution. + * Tries JS types first, then falls back to Java types. + * + * @param typeName The type name (can be simple or fully-qualified) + * @return TypeInfo for the resolved type, or null if not found + */ + public TypeInfo resolve(String typeName) { + if (typeName == null || typeName.isEmpty()) { + return null; + } + + // Try JS resolution first (handles primitives, .d.ts types) + TypeInfo jsType = resolveJSType(typeName); + if (jsType != null && jsType.isResolved()) { + return jsType; + } + + // Try full name resolution + TypeInfo fullType = resolveFullName(typeName); + if (fullType != null && fullType.isResolved()) { + return fullType; + } + + // Return unresolved or null + return jsType; // Will be unresolved TypeInfo or null + } + + /** + * Resolve a type name for JavaScript context. + * Handles JS primitives, .d.ts types, and falls back to Java types. + * NOW preserves generic type arguments in the returned TypeInfo. + * + * @param jsTypeName The JS type name (e.g., "IPlayer", "string", "List", "Map") + * @return TypeInfo for the resolved type, or unresolved TypeInfo if not found + */ + public TypeInfo resolveJSType(String jsTypeName) { + if (jsTypeName == null || jsTypeName.isEmpty()) { + return TypeInfo.fromPrimitive("void"); + } + + // Normalize import() references first. + String normalized = TypeStringNormalizer.stripImportTypeSyntax(jsTypeName); + if (normalized == null || normalized.isEmpty()) { + return TypeInfo.fromPrimitive("void"); + } + + // Unions: pick a single "best" branch for resolution (prefer non-nullish). + normalized = TypeStringNormalizer.pickPreferredUnionBranch(normalized); + + // Nullable: treat Foo? as Foo | null (for resolution purposes this means "resolve Foo"). + normalized = TypeStringNormalizer.stripNullableSuffix(normalized); + + // Arrays: handled here, not in the generics structural parser. + TypeStringNormalizer.ArraySplit arraySplit = TypeStringNormalizer.splitArraySuffixes(normalized); + String baseExpr = arraySplit.base; + int arrayDims = arraySplit.dimensions; + + if (baseExpr == null || baseExpr.isEmpty()) { + return TypeInfo.unresolved(jsTypeName, jsTypeName); + } + + TypeInfo resolved = null; + // Fast path: no generics - skip expensive parsing + if (!baseExpr.contains("<")) { + resolved = resolveBaseType(baseExpr); + } else { + // Slow path: parse and resolve into a TypeInfo, preserving generic arguments. + GenericTypeParser.ParsedType parsed = GenericTypeParser.parse(baseExpr); + if (parsed != null) { + // Resolve the base type (without generics) + TypeInfo baseType = resolveBaseType(parsed.baseName); + + // If we have type arguments, resolve them (allowing each arg to have its own arrays/unions/etc). + if (parsed.hasTypeArgs() && baseType != null && baseType.isResolved()) { + List resolvedArgs = new ArrayList<>(); + for (GenericTypeParser.ParsedType argParsed : parsed.typeArgs) { + if (argParsed == null) { + resolvedArgs.add(TypeInfo.ANY); + continue; + } + TypeInfo argType = resolveJSType(argParsed.rawString); + resolvedArgs.add(argType != null ? argType : TypeInfo.unresolved(argParsed.baseName, + argParsed.baseName)); + } + if (!resolvedArgs.isEmpty()) { + baseType = baseType.parameterize(resolvedArgs); + } + } + resolved = baseType != null ? baseType : TypeInfo.unresolved(parsed.baseName, parsed.rawString); + } else + resolved = resolveBaseType(baseExpr); + } + + if (resolved == null) { + resolved = TypeInfo.unresolved(baseExpr, jsTypeName); + } + + for (int i = 0; i < arrayDims; i++) { + resolved = TypeInfo.arrayOf(resolved); + } + + return resolved; + } + + /** + * Resolve a base type name (without generics) to a TypeInfo. + * This handles primitives, synthetic types, JS registry types, and Java types. + */ + private TypeInfo resolveBaseType(String baseName) { + if (baseName == null || baseName.isEmpty()) { + return null; + } + + // Handle "Java." prefix - convert to actual Java type + if (baseName.startsWith("Java.")) { + baseName = baseName.substring(5); + } + + // Check synthetic types (Nashorn built-ins, custom types, etc.) + if (isSyntheticType(baseName)) { + SyntheticType syntheticType = getSyntheticType(baseName); + return syntheticType.getTypeInfo(); + } + + // Check primitives and common types + switch (baseName.toLowerCase()) { + case "string": + return TypeInfo.STRING; + case "number": + case "int": + case "integer": + return TypeInfo.NUMBER; + case "boolean": + case "bool": + return TypeInfo.BOOLEAN; + case "void": + return TypeInfo.VOID; + case "any": + case "object": + case "*": + return TypeInfo.ANY; + } + + // Check JS type registry + JSTypeInfo jsTypeInfo = getJSTypeRegistry().getType(baseName); + if (jsTypeInfo != null) { + return TypeInfo.fromJSTypeInfo(jsTypeInfo); + } + + // Fall back to Java type resolution + TypeInfo javaType = resolveFullName(baseName); + if (javaType != null && javaType.isResolved()) { + return javaType; + } + + // Unresolved + return TypeInfo.unresolved(baseName, baseName); + } + + /** + * Check if a JS type name is a primitive. + */ + public boolean isJSPrimitive(String typeName) { + return JS_PRIMITIVE_TO_JAVA.containsKey(typeName); + } + + // ==================== TYPE RESOLUTION ==================== + + /** + * Resolve a fully-qualified class name. + * Handles inner classes with both dot and dollar notation. + */ + public TypeInfo resolveFullName(String fullName) { + if (fullName == null || fullName.isEmpty()) { + return null; + } + + // Normalize: remove whitespace around dots + String normalized = fullName.replaceAll("\\s*\\.\\s*", ".").trim(); + + // Check cache first + if (typeCache.containsKey(normalized)) { + return typeCache.get(normalized); + } + + // Try direct resolution + TypeInfo result = tryResolveClass(normalized); + if (result != null) { + return result; + } + + // Try converting dots to $ for inner classes + // Work backwards from the end, trying each segment as an inner class + String[] segments = normalized.split("\\."); + for (int i = segments.length - 1; i > 0; i--) { + StringBuilder candidate = new StringBuilder(); + // Package portion + for (int j = 0; j < i; j++) { + if (j > 0) candidate.append('.'); + candidate.append(segments[j]); + } + // Class portion with $ + for (int j = i; j < segments.length; j++) { + candidate.append(j == i ? '.' : '$'); + candidate.append(segments[j]); + } + + result = tryResolveClass(candidate.toString()); + if (result != null) { + // Cache the original lookup + typeCache.put(normalized, result); + return result; + } + } + + // Not found - cache the miss + typeCache.put(normalized, null); + return null; + } + + /** + * Resolve a simple class name using provided import context. + */ + public TypeInfo resolveSimpleName(String simpleName, + Map imports, + Set wildcardPackages) { + if (simpleName == null || simpleName.isEmpty()) { + return TypeInfo.unresolved(simpleName,simpleName); + } + + // 1. Check explicit imports + ImportData importData = imports.get(simpleName); + if (importData != null && importData.getResolvedType() != null) { + return importData.getResolvedType(); + } + if (importData != null) { + TypeInfo resolved = resolveFullName(importData.getFullPath()); + if (resolved != null) { + importData.setResolvedType(resolved); + return resolved; + } + } + + // 2. Check java.lang + if (JAVA_LANG_CLASSES.contains(simpleName)) { + TypeInfo langType = resolveFullName("java.lang." + simpleName); + if (langType != null) { + return langType; + } + } + + // 3. Check wildcard packages + if (wildcardPackages != null) { + for (String pkg : wildcardPackages) { + // Try as package.ClassName + TypeInfo fromPkg = resolveFullName(pkg + "." + simpleName); + if (fromPkg != null) { + return fromPkg; + } + + // Try as OuterClass$InnerClass (for class-level wildcards like IOverlay.*) + TypeInfo fromInner = resolveFullName(pkg + "$" + simpleName); + if (fromInner != null) { + return fromInner; + } + } + } + + return TypeInfo.unresolved(simpleName,simpleName); + } + + /** + * Try to load a class and create TypeInfo for it. + */ + private TypeInfo tryResolveClass(String className) { + if (className == null || className.isEmpty()) { + return null; + } + + // Check cache + if (typeCache.containsKey(className)) { + return typeCache.get(className); + } + + try { + Class clazz = Class.forName(className); + TypeInfo info = TypeInfo.fromClass(clazz); + typeCache.put(className, info); + + // Register the package as valid + registerPackage(info.getPackageName()); + + return info; + } catch (ClassNotFoundException e) { + // Cache the miss + typeCache.put(className, null); + return null; + } catch (LinkageError e) { + // NoClassDefFoundError is a subclass of LinkageError + typeCache.put(className, null); + return null; + } + } + + // ==================== PACKAGE VALIDATION ==================== + + /** + * Check if a package path is valid. + */ + public boolean isValidPackage(String packagePath) { + if (packagePath == null || packagePath.isEmpty()) { + return false; + } + + if (validPackages.contains(packagePath)) { + return true; + } + + if (PackageFinder.find(packagePath)) { + registerPackage(packagePath); + return true; + } + + // Try to find a class in this package to validate it + String[] testClasses = getTestClassesForPackage(packagePath); + for (String testClass : testClasses) { + try { + Class.forName(testClass); + registerPackage(packagePath); + return true; + } catch (ClassNotFoundException | LinkageError ignored) { + } + } + + return false; + } + + /** + * Register a package path as valid (and all parent packages). + */ + private void registerPackage(String packagePath) { + if (packagePath == null || packagePath.isEmpty()) { + return; + } + validPackages.add(packagePath); + + // Also register parent packages + int lastDot; + String current = packagePath; + while ((lastDot = current.lastIndexOf('.')) > 0) { + current = current.substring(0, lastDot); + validPackages.add(current); + } + } + + private String[] getTestClassesForPackage(String packagePath) { + switch (packagePath) { + case "java": + return new String[]{"java.lang.Object"}; + case "java.util": + return new String[]{"java.util.List", "java.util.Map"}; + case "java.io": + return new String[]{"java.io.File", "java.io.InputStream"}; + case "java.net": + return new String[]{"java.net.URL", "java.net.Socket"}; + case "java.lang": + return new String[]{"java.lang.Object", "java.lang.String"}; + default: + return new String[]{}; + } + } + + // ==================== IMPORT RESOLUTION ==================== + + /** + * Resolve all imports and return a map of simple name -> ImportData. + */ + public Map resolveImports(List imports) { + Map resolved = new HashMap<>(); + + for (ImportData imp : imports) { + if (imp.isWildcard()) { + // Wildcard imports: validate the package exists + imp.markResolved(isValidPackage(imp.getFullPath()) || + resolveFullName(imp.getFullPath()) != null); + } else { + // Specific import: resolve the class + TypeInfo typeInfo = resolveFullName(imp.getFullPath()); + imp.setResolvedType(typeInfo); + if (imp.getSimpleName() != null) { + resolved.put(imp.getSimpleName(), imp); + } + } + } + + return resolved; + } + + // ==================== GENERIC TYPE PARSING ==================== + + /** + * Parse type names from generic content like "Map>". + * Returns TypeInfo for each type found. + */ + public List parseGenericTypes(String content, + Map imports, + Set wildcardPackages) { + List results = new ArrayList<>(); + parseGenericTypesRecursive(content, 0, imports, wildcardPackages, results); + return results; + } + + private void parseGenericTypesRecursive(String content, int baseOffset, + Map imports, + Set wildcardPackages, + List results) { + if (content == null || content.isEmpty()) return; + + int i = 0; + while (i < content.length()) { + char c = content.charAt(i); + + // Skip non-identifier characters + if (!Character.isJavaIdentifierStart(c)) { + i++; + continue; + } + + // Found start of identifier + int start = i; + while (i < content.length() && Character.isJavaIdentifierPart(content.charAt(i))) { + i++; + } + String typeName = content.substring(start, i); + + // Only process uppercase-starting identifiers as types + if (Character.isUpperCase(typeName.charAt(0))) { + TypeInfo info = resolveSimpleName(typeName, imports, wildcardPackages); + results.add(new GenericTypeOccurrence( + baseOffset + start, + baseOffset + i, + typeName, + info + )); + } + + // Skip whitespace + while (i < content.length() && Character.isWhitespace(content.charAt(i))) { + i++; + } + + // Check for nested generic + if (i < content.length() && content.charAt(i) == '<') { + int nestedStart = i + 1; + int depth = 1; + i++; + + while (i < content.length() && depth > 0) { + if (content.charAt(i) == '<') depth++; + else if (content.charAt(i) == '>') depth--; + i++; + } + + // Recursively parse nested content + if (nestedStart < i - 1) { + String nestedContent = content.substring(nestedStart, i - 1); + parseGenericTypesRecursive(nestedContent, baseOffset + nestedStart, + imports, wildcardPackages, results); + } + } + } + } + + + /** + * Check if a type name is a primitive type. + */ + public static boolean isPrimitiveType(String typeName) { + return typeName.equals("boolean") || typeName.equals("byte") || typeName.equals("char") || + typeName.equals("short") || typeName.equals("int") || typeName.equals("long") || + typeName.equals("float") || typeName.equals("double") || typeName.equals("void"); + } + + /** + * Check if a word is a Java modifier keyword. + */ + public static boolean isModifier(String word) { + return word.equals("public") || word.equals("private") || word.equals("protected") || + word.equals("static") || word.equals("final") || word.equals("abstract") || + word.equals("synchronized") || word.equals("volatile") || word.equals("transient") || + word.equals("native") || word.equals("strictfp"); + } + + // ==================== CLASS SEARCH ==================== + + /** + * Search for classes matching the given prefix. + * Uses ClassIndex for O(n) lookup where n is the number of indexed classes. + * + * @param prefix The prefix to match (case-sensitive for class names) + * @param maxResults Maximum number of results to return + * @return List of fully-qualified class names that match + */ + public List findClassesByPrefix(String prefix, int maxResults) { + return ClassIndex.getInstance().findByPrefix(prefix, maxResults); + } + + /** + * @deprecated Use findClassesByPrefix instead for prefix matching + */ + @Deprecated + public List findClassesBySimpleName(String simpleName, int maxResults) { + // Redirect to prefix matching for backwards compatibility + return findClassesByPrefix(simpleName, maxResults); + } + + /** + * Represents a type occurrence within generic content. + */ + public static class GenericTypeOccurrence { + public final int startOffset; + public final int endOffset; + public final String typeName; + public final TypeInfo typeInfo; + + public GenericTypeOccurrence(int startOffset, int endOffset, String typeName, TypeInfo typeInfo) { + this.startOffset = startOffset; + this.endOffset = endOffset; + this.typeName = typeName; + this.typeInfo = typeInfo; + } + + public TokenType getTokenType() { + if (typeInfo == null || !typeInfo.isResolved()) { + return TokenType.UNDEFINED_VAR; + } + return typeInfo.getTokenType(); + } + } + + /** + * Check if an expression represents static access (accessing a Class, not an instance). + * This checks if the expression STRING resolves to a TYPE name. + * + * Examples: + * - "String" -> true (it's a class name) + * - "myVar" -> false (it's a variable, even if myVar holds a ClassTypeInfo) + * - "Java" -> true (it's a synthetic type) + * + * Note: This is different from checking if a variable's TYPE is ClassTypeInfo. + * For variables holding ClassTypeInfo (like `var File = Java.type("File")`), + * the receiver type itself should be checked separately. + * + * @param typeInfo The resolved TypeInfo (can be null) + * @param wasResolvedAsType true if this was resolved as a type name (not a variable) + * @return true if this represents static access (direct class reference) + */ + public static boolean isStaticAccess(TypeInfo typeInfo, boolean wasResolvedAsType) { + // If explicitly resolved as a type name, it's static access + if (wasResolvedAsType) + return true; + + if (typeInfo == null) + return false; + + // If the typeInfo itself is a ClassTypeInfo (e.g., result of Java.type()), + // then accessing members on it should be static + if (typeInfo.isClassReference()) + return true; + + return false; + } + + /** + * Unified static access checker for expressions. + * Determines if an identifier represents static access (type name) or instance access (variable). + * + * This method is used by ScriptDocument, JavaAutocompleteProvider, and FieldChainMarker. + * + * @param identifier The identifier to check (e.g., "String", "myVar", "Java") + * @param position The position in the document for context + * @param document The script document for resolution + * @return true if this represents static access + */ + public static boolean isStaticAccessExpression(String identifier, int position, ScriptDocument document) { + if (identifier == null || identifier.isEmpty() || document == null) { + return false; + } + + // If the identifier contains dots, parentheses, brackets, or operators, it's a complex expression + // Complex expressions (method calls, field chains, arithmetic) are instance access by default + if (identifier.contains(".") || identifier.contains("(") || identifier.contains("[") || + document.containsOperators(identifier)) { + // For complex expressions, resolve as expression and check the result type + TypeInfo exprType = document.resolveExpressionType(identifier, position); + // Only static if the expression result is itself a ClassTypeInfo (e.g., Java.type("File")) + return isStaticAccess(exprType, false); + } + + // Simple identifier - check if it's a variable/field (instance access) + FieldInfo fieldInfo = document.resolveVariable(identifier, position); + if (fieldInfo != null && fieldInfo.isResolved()) { + // It's a variable - check if its type is a ClassTypeInfo + return isStaticAccess(fieldInfo.getTypeInfo(), false); + } + + // Not a variable - check if it's a type name by trying to resolve as type + TypeInfo typeCheck = document.resolveType(identifier); + boolean isType = typeCheck != null && typeCheck.isResolved(); + return isStaticAccess(typeCheck, isType); + } + + /** + * Parse string literal arguments from a method call. + * Used for resolving dynamic return types like Java.type("className"). + * + * @param argumentsStr The arguments string (e.g., "\"java.io.File\", 123") + * @return Array of parsed arguments + */ + public static String[] parseStringArguments(String argumentsStr) { + if (argumentsStr == null || argumentsStr.isEmpty()) { + return new String[0]; + } + + List args = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + int depth = 0; + boolean inString = false; + char stringChar = 0; + + for (int i = 0; i < argumentsStr.length(); i++) { + char c = argumentsStr.charAt(i); + + if (inString) { + current.append(c); + if (c == stringChar && (i == 0 || argumentsStr.charAt(i - 1) != '\\')) { + inString = false; + } + } else if (c == '"' || c == '\'') { + current.append(c); + inString = true; + stringChar = c; + } else if (c == '(' || c == '[' || c == '{') { + depth++; + current.append(c); + } else if (c == ')' || c == ']' || c == '}') { + depth--; + current.append(c); + } else if (c == ',' && depth == 0) { + args.add(current.toString().trim()); + current = new StringBuilder(); + } else { + current.append(c); + } + } + + if (current.length() > 0) { + args.add(current.toString().trim()); + } + + return args.toArray(new String[0]); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeStringNormalizer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeStringNormalizer.java new file mode 100644 index 000000000..fd464f6d3 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeStringNormalizer.java @@ -0,0 +1,221 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shared utilities for cleaning and normalizing type expressions. + * + * This is intentionally a small, dependency-light helper that both the runtime resolver + * and the .d.ts parser can use. + */ +public final class TypeStringNormalizer { + + private TypeStringNormalizer() {} + + public static final class ArraySplit { + public final String base; + public final int dimensions; + + public ArraySplit(String base, int dimensions) { + this.base = base; + this.dimensions = dimensions; + } + } + + /** + * Strip TypeScript import() type references anywhere in the string. + * + * Examples: + * - import('./data/IAction').IAction -> IAction + * - Java.java.util.function.Consumer -> Java.java.util.function.Consumer + */ + public static String stripImportTypeSyntax(String type) { + if (type == null) { + return null; + } + + String out = type.trim(); + int importIdx; + while ((importIdx = out.indexOf("import(")) >= 0) { + int i = importIdx + "import(".length(); + int depth = 1; + boolean inString = false; + char stringChar = 0; + + while (i < out.length() && depth > 0) { + char c = out.charAt(i); + if (inString) { + if (c == stringChar && (i == 0 || out.charAt(i - 1) != '\\')) { + inString = false; + } + } else { + if (c == '\'' || c == '"') { + inString = true; + stringChar = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } + } + i++; + } + + // i is positioned just after the matching ')', if found. + if (depth != 0) { + break; // Unbalanced import( ... ) - stop trying to clean. + } + + // If immediately followed by ".", remove the "import(...)." prefix. + if (i < out.length() && out.charAt(i) == '.') { + int removeEnd = i + 1; + out = out.substring(0, importIdx) + out.substring(removeEnd); + } else { + // Remove just the import(...) portion. + out = out.substring(0, importIdx) + out.substring(i); + } + } + + return out; + } + + /** + * Split a trailing TypeScript nullable suffix: Foo? -> Foo. + */ + public static String stripNullableSuffix(String type) { + if (type == null) { + return null; + } + String out = type.trim(); + if (out.endsWith("?")) { + return out.substring(0, out.length() - 1).trim(); + } + return out; + } + + /** + * Split trailing array suffixes: Foo[][] -> base=Foo, dimensions=2. + */ + public static ArraySplit splitArraySuffixes(String type) { + if (type == null) { + return new ArraySplit(null, 0); + } + String out = type.trim(); + int dims = 0; + while (out.endsWith("[]")) { + dims++; + out = out.substring(0, out.length() - 2).trim(); + } + return new ArraySplit(out, dims); + } + + /** + * Split a top-level union (depth-aware) into branches. + */ + public static List splitTopLevelUnion(String type) { + List parts = new ArrayList<>(); + if (type == null) { + return parts; + } + + String expr = type.trim(); + if (expr.isEmpty()) { + parts.add(""); + return parts; + } + + int depth = 0; + boolean inString = false; + char stringChar = 0; + + int start = 0; + for (int i = 0; i < expr.length(); i++) { + char c = expr.charAt(i); + + if (inString) { + if (c == stringChar && (i == 0 || expr.charAt(i - 1) != '\\')) { + inString = false; + } + continue; + } + + if (c == '\'' || c == '"' || c == '`') { + inString = true; + stringChar = c; + continue; + } + + if (c == '<' || c == '(' || c == '[' || c == '{') { + depth++; + continue; + } + if (c == '>' || c == ')' || c == ']' || c == '}') { + if (depth > 0) { + depth--; + } + continue; + } + + if (c == '|' && depth == 0) { + String part = expr.substring(start, i).trim(); + if (!part.isEmpty()) { + parts.add(part); + } + start = i + 1; + } + } + + String tail = expr.substring(start).trim(); + if (!tail.isEmpty()) { + parts.add(tail); + } + + if (parts.isEmpty()) { + parts.add(expr); + } + return parts; + } + + /** + * Choose a single branch to represent a union type for resolution. + * + * Policy: + * - Prefer the first non-nullish branch (not null/undefined/void). + * - Otherwise, fall back to the first branch. + */ + public static String pickPreferredUnionBranch(String type) { + if (type == null) { + return null; + } + + List parts = splitTopLevelUnion(type); + if (parts.size() <= 1) { + return type.trim(); + } + + for (String part : parts) { + if (!isNullishBranch(part)) { + return part.trim(); + } + } + + return parts.get(0).trim(); + } + + private static boolean isNullishBranch(String part) { + if (part == null) { + return false; + } + + String s = part.trim(); + if (s.isEmpty()) { + return false; + } + + // Strip generics so "null<...>" doesn't behave weirdly. + s = GenericTypeParser.stripGenerics(s); + s = s.trim().toLowerCase(); + return "null".equals(s) || "undefined".equals(s) || "void".equals(s); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeSubstitutor.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeSubstitutor.java new file mode 100644 index 000000000..514174743 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeSubstitutor.java @@ -0,0 +1,197 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.interpreter.js_parser.TypeParamInfo; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility for substituting type variables (like T, E, K, V) with concrete types. + * + * Used when resolving members on parameterized types: + * - List.get(int) returns String (not T/Object) + * - Map.get(Object) returns Integer (not V) + * + * Handles nested generics: + * - List>.get(0) returns Map + */ +public class TypeSubstitutor { + + /** + * Create a binding map from declared type parameters to applied type arguments. + * + * Example: For List where List is declared as interface List: + * - declaredParams = [TypeParamInfo("E", ...)] + * - appliedArgs = [TypeInfo(String)] + * - Result: {"E" -> TypeInfo(String)} + * + * @param declaredParams The declared type parameters (from the generic type definition) + * @param appliedArgs The applied type arguments (from the parameterized usage) + * @return A map from type parameter names to their concrete TypeInfo values + */ + public static Map createBindings(List declaredParams, List appliedArgs) { + Map bindings = new HashMap<>(); + + if (declaredParams == null || appliedArgs == null) { + return bindings; + } + + int count = Math.min(declaredParams.size(), appliedArgs.size()); + for (int i = 0; i < count; i++) { + String paramName = declaredParams.get(i).getName(); + TypeInfo argType = appliedArgs.get(i); + if (paramName != null && argType != null) { + bindings.put(paramName, argType); + } + } + + return bindings; + } + + /** + * Create bindings from a parameterized receiver type. + * + * @param receiverType The parameterized type (e.g., List) + * @return Bindings from the type's declared params to its applied args + */ + public static Map createBindingsFromReceiver(TypeInfo receiverType) { + if (receiverType == null) { + return new HashMap<>(); + } + + // Get the raw type to access declared type parameters + TypeInfo rawType = receiverType.getRawType(); + List declaredParams = null; + + // Get declared params from JSTypeInfo or reflection + if (rawType.isJSType() && rawType.getJSTypeInfo() != null) { + declaredParams = rawType.getJSTypeInfo().getTypeParams(); + } else { + declaredParams = rawType.getTypeParams(); + } + + // Get applied args from the parameterized type + List appliedArgs = receiverType.getAppliedTypeArgs(); + + return createBindings(declaredParams, appliedArgs); + } + + /** + * Substitute type variables in a type using the given bindings. + * + * @param type The type to substitute (may contain type variables like T, E) + * @param bindings The map from type variable names to concrete types + * @return The substituted type + */ + public static TypeInfo substitute(TypeInfo type, Map bindings) { + if (type == null || bindings == null || bindings.isEmpty()) { + return type; + } + + String typeName = type.getSimpleName(); + + // Check if this type is itself a type variable + if (!type.isResolved()) { + TypeInfo substitution = bindings.get(typeName); + if (substitution != null) { + return substitution; + } + } + + // If the type has applied type arguments, substitute within them recursively + if (type.isParameterized()) { + List originalArgs = type.getAppliedTypeArgs(); + List substitutedArgs = new ArrayList<>(); + boolean changed = false; + + for (TypeInfo arg : originalArgs) { + TypeInfo substitutedArg = substitute(arg, bindings); + substitutedArgs.add(substitutedArg); + if (substitutedArg != arg) { + changed = true; + } + } + + if (changed) { + // Create a new parameterized type with substituted args + return type.getRawType().parameterize(substitutedArgs); + } + } + + // Handle array types - substitute the element type + if (type.getSimpleName().endsWith("[]")) { + TypeStringNormalizer.ArraySplit split = TypeStringNormalizer.splitArraySuffixes(typeName); + String elementTypeName = split.base; + int dims = split.dimensions; + TypeInfo elementSubstitution = elementTypeName != null ? bindings.get(elementTypeName) : null; + if (elementSubstitution != null) { + TypeInfo result = elementSubstitution; + for (int i = 0; i < dims; i++) { + result = TypeInfo.arrayOf(result); + } + return result; + } + } + + return type; + } + + /** + * Substitute type variables in a type string using the given bindings. + * Used when we have a raw type string (like "T" or "List") that needs substitution. + * + * @param typeString The raw type string + * @param bindings The map from type variable names to concrete types + * @param resolver The type resolver to use for parsing + * @return The substituted TypeInfo + */ + public static TypeInfo substituteString(String typeString, Map bindings, TypeResolver resolver) { + if (typeString == null || typeString.isEmpty()) { + return null; + } + + // First check if the whole string is a type variable + String trimmed = TypeStringNormalizer.stripImportTypeSyntax(typeString); + trimmed = TypeStringNormalizer.pickPreferredUnionBranch(trimmed); + trimmed = TypeStringNormalizer.stripNullableSuffix(trimmed); + + TypeStringNormalizer.ArraySplit arraySplit = TypeStringNormalizer.splitArraySuffixes(trimmed); + String baseName = arraySplit.base; + int arrayDims = arraySplit.dimensions; + + // Strip generic args to check the base name + String bareBaseName = GenericTypeParser.stripGenerics(baseName); + + // If bare base name is a type variable, substitute + TypeInfo directSubstitution = bindings.get(bareBaseName); + if (directSubstitution != null) { + // It's a type variable, return the substitution + TypeInfo result = directSubstitution; + for (int i = 0; i < arrayDims; i++) { + result = TypeInfo.arrayOf(result); + } + return result; + } + + // Parse and resolve the type, then substitute within it + TypeInfo resolved = resolver.resolveJSType(typeString); + return substitute(resolved, bindings); + } + + /** + * Get the return type for a method on a parameterized receiver, with type variable substitution. + * + * @param rawReturnType The method's raw return type (may contain type variables) + * @param rawReturnTypeString The method's raw return type as a string (for unresolved types) + * @param receiverType The parameterized receiver type + * @param resolver The type resolver + * @return The substituted return type + */ + public static TypeInfo getSubstitutedReturnType(TypeInfo rawReturnType, String rawReturnTypeString, + TypeInfo receiverType, TypeResolver resolver) { + GenericContext ctx = GenericContext.forReceiver(receiverType); + return ctx.substituteType(rawReturnType, rawReturnTypeString, resolver); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticField.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticField.java new file mode 100644 index 000000000..4bb2bc684 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticField.java @@ -0,0 +1,45 @@ +package noppes.npcs.client.gui.util.script.interpreter.type.synthetic; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.lang.reflect.Modifier; + +public class SyntheticField { + public final String name; + public final String typeName; + public final String documentation; + public final boolean isStatic; + + SyntheticField(String name, String typeName, String documentation, boolean isStatic) { + this.name = name; + this.typeName = typeName; + this.documentation = documentation; + this.isStatic = isStatic; + } + + /** + * Get the field type as TypeInfo. + * @return The resolved TypeInfo for the field type, or unresolved if not found + */ + public TypeInfo getTypeInfo() { + TypeInfo type = TypeResolver.getInstance().resolve(typeName); + if (type == null) { + type = TypeInfo.unresolved(typeName, typeName); + } + return type; + } + + public FieldInfo toFieldInfo() { + TypeInfo type = TypeResolver.getInstance().resolve(typeName); + if (type == null) { + type = TypeInfo.unresolved(typeName, typeName); + } + int modifiers = Modifier.PUBLIC; + if (isStatic) { + modifiers |= Modifier.STATIC; + } + return FieldInfo.external(name, type, documentation, modifiers); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticMethod.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticMethod.java new file mode 100644 index 000000000..acae6098e --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticMethod.java @@ -0,0 +1,160 @@ +package noppes.npcs.client.gui.util.script.interpreter.type.synthetic; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocParamTag; +import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocReturnTag; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SyntheticMethod { + public final String name; + public final String returnType; + public final List parameters; + public final String documentation; + public final boolean isStatic; + public final SyntheticTypeBuilder.ReturnTypeResolver returnTypeResolver; + + SyntheticMethod(String name, String returnType, List parameters, + String documentation, boolean isStatic, + SyntheticTypeBuilder.ReturnTypeResolver returnTypeResolver) { + this.name = name; + this.returnType = returnType; + this.parameters = Collections.unmodifiableList(new ArrayList<>(parameters)); + this.documentation = documentation; + this.isStatic = isStatic; + this.returnTypeResolver = returnTypeResolver; + } + + /** + * Get the return type as TypeInfo. + * @return The resolved TypeInfo for the return type, or unresolved if not found + */ + public TypeInfo getReturnTypeInfo() { + TypeInfo returnTypeInfo = TypeResolver.getInstance().resolve(returnType); + if (returnTypeInfo == null) { + returnTypeInfo = TypeInfo.unresolved(returnType, returnType); + } + return returnTypeInfo; + } + + /** + * Resolve the return type with dynamic arguments (for methods like Java.type). + * @param arguments The method call arguments + * @return The dynamically resolved TypeInfo, or static return type if no resolver + */ + public TypeInfo resolveReturnType(String[] arguments) { + if (returnTypeResolver != null) { + return returnTypeResolver.resolve(arguments); + } + return getReturnTypeInfo(); + } + + /** + * Create a MethodInfo from this synthetic method. + */ + public MethodInfo toMethodInfo(TypeInfo containingType) { + List paramInfos = new ArrayList<>(); + for (SyntheticParameter param : parameters) { + TypeInfo paramType = TypeResolver.getInstance().resolve(param.typeName); + if (paramType == null) { + paramType = TypeInfo.unresolved(param.typeName, param.typeName); + } + paramInfos.add(FieldInfo.parameter(param.name, paramType, -1, null)); + } + + TypeInfo returnTypeInfo = TypeResolver.getInstance().resolve(returnType); + if (returnTypeInfo == null) { + returnTypeInfo = TypeInfo.unresolved(returnType, returnType); + } + + int modifiers = Modifier.PUBLIC; + if (isStatic) { + modifiers |= Modifier.STATIC; + } + + MethodInfo methodInfo = MethodInfo.external(name, returnTypeInfo, containingType, paramInfos, modifiers, null); + + // Create JSDocInfo from documentation + if (documentation != null && !documentation.isEmpty()) { + JSDocInfo jsDocInfo = createJSDocInfo(returnTypeInfo); + methodInfo.setJSDocInfo(jsDocInfo); + } + + return methodInfo; + } + + /** + * Create JSDocInfo from the documentation string. + */ + private JSDocInfo createJSDocInfo(TypeInfo returnTypeInfo) { + // Parse the documentation to extract description and separate sections + String[] lines = documentation.split("\\n"); + StringBuilder descBuilder = new StringBuilder(); + + // Extract description (everything before @param or @returns) + for (String line : lines) { + line = line.trim(); + if (line.startsWith("@param") || line.startsWith("@returns") || line.startsWith("@return")) { + break; + } + if (!line.isEmpty()) { + if (descBuilder.length() > 0) + descBuilder.append("\n"); + descBuilder.append(line); + } + } + + JSDocInfo jsDocInfo = new JSDocInfo(documentation, -1, -1); + jsDocInfo.setDescription(descBuilder.toString()); + + // Add @param tags for each parameter + for (int i = 0; i < parameters.size(); i++) { + SyntheticParameter param = parameters.get(i); + TypeInfo paramType = TypeResolver.getInstance().resolve(param.typeName); + if (paramType == null) { + paramType = TypeInfo.unresolved(param.typeName, param.typeName); + } + + JSDocParamTag paramTag = JSDocParamTag.create( + -1, -1, -1, // offsets + param.typeName, paramType, -1, -1, // type info + param.name, -1, -1, // param name + param.documentation // description + ); + jsDocInfo.addParamTag(paramTag); + } + + // Add @returns tag + JSDocReturnTag returnTag = JSDocReturnTag.create( + "returns", -1, -1, -1, // offsets + returnTypeInfo.getSimpleName(), returnTypeInfo, -1, -1, // type info + null // description extracted from main documentation + ); + jsDocInfo.setReturnTag(returnTag); + + return jsDocInfo; + } + + /** + * Get signature string for display. + */ + public String getSignature() { + StringBuilder sb = new StringBuilder(); + sb.append(name).append("("); + for (int i = 0; i < parameters.size(); i++) { + if (i > 0) + sb.append(", "); + SyntheticParameter p = parameters.get(i); + sb.append(p.name).append(": ").append(p.typeName); + } + sb.append("): ").append(returnType); + return sb.toString(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticParameter.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticParameter.java new file mode 100644 index 000000000..bf9a5fe97 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticParameter.java @@ -0,0 +1,28 @@ +package noppes.npcs.client.gui.util.script.interpreter.type.synthetic; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; + +public class SyntheticParameter { + public final String name; + public final String typeName; + public final String documentation; + + SyntheticParameter(String name, String typeName, String documentation) { + this.name = name; + this.typeName = typeName; + this.documentation = documentation; + } + + /** + * Get the parameter type as TypeInfo. + * @return The resolved TypeInfo for the parameter type, or unresolved if not found + */ + public TypeInfo getTypeInfo() { + TypeInfo type = TypeResolver.getInstance().resolve(typeName); + if (type == null) { + type = TypeInfo.unresolved(typeName, typeName); + } + return type; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticType.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticType.java new file mode 100644 index 000000000..01878d2f5 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticType.java @@ -0,0 +1,95 @@ +package noppes.npcs.client.gui.util.script.interpreter.type.synthetic; + +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.*; + +/** + * Represents a fully-built synthetic type. + */ +public class SyntheticType { + private final String name; + private final String documentation; + private final Map methods; + private final Map fields; + private TypeInfo typeInfo; + + SyntheticType(String name, String documentation, List methods, + List fields) { + this.name = name; + this.documentation = documentation; + this.methods = new LinkedHashMap<>(); + for (SyntheticMethod m : methods) { + this.methods.put(m.name, m); + } + this.fields = new LinkedHashMap<>(); + for (SyntheticField f : fields) { + this.fields.put(f.name, f); + } + } + + public String getName() { + return name; + } + + public String getDocumentation() { + return documentation; + } + + public SyntheticMethod getMethod(String methodName) { + return methods.get(methodName); + } + + public Collection getMethods() { + return methods.values(); + } + + public SyntheticField getField(String fieldName) { + return fields.get(fieldName); + } + + public Collection getFields() { + return fields.values(); + } + + public boolean hasMethod(String methodName) { + return methods.containsKey(methodName); + } + + public boolean hasField(String fieldName) { + return fields.containsKey(fieldName); + } + + /** + * Get or create the TypeInfo for this synthetic type. + */ + public TypeInfo getTypeInfo() { + if (typeInfo == null) { + typeInfo = TypeInfo.resolved(name, name, "", TypeInfo.Kind.CLASS, null); + } + return typeInfo; + } + + /** + * Create a MethodInfo for a method in this type. + */ + public MethodInfo getMethodInfo(String methodName) { + SyntheticMethod method = methods.get(methodName); + if (method == null) + return null; + return method.toMethodInfo(getTypeInfo()); + } + + /** + * Resolve the return type of a method given arguments. + * Used for special methods like Java.type(). + */ + public TypeInfo resolveMethodReturnType(String methodName, String[] arguments) { + SyntheticMethod method = methods.get(methodName); + if (method == null) + return null; + + return method.resolveReturnType(arguments); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticTypeBuilder.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticTypeBuilder.java new file mode 100644 index 000000000..02fc4d586 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/synthetic/SyntheticTypeBuilder.java @@ -0,0 +1,165 @@ +package noppes.npcs.client.gui.util.script.interpreter.type.synthetic; + +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.*; + +/** + * Builder for creating synthetic (non-reflection-based) types. + * Used for built-in Nashorn objects like 'Java' that have no corresponding Java class. + * + *

Usage:

+ *
+ * SyntheticType javaType = new SyntheticTypeBuilder("Java")
+ *     .addMethod("type")
+ *         .parameter("className", "string")
+ *         .returns("Class")
+ *         .documentation("Loads a Java class by fully-qualified name.")
+ *         .done()
+ *     .build();
+ * 
+ */ +public class SyntheticTypeBuilder { + + private final String name; + private String documentation; + private final List methods = new ArrayList<>(); + private final List fields = new ArrayList<>(); + + public SyntheticTypeBuilder(String name) { + this.name = name; + } + + public SyntheticTypeBuilder documentation(String doc) { + this.documentation = doc; + return this; + } + + /** + * Start building a method for this type. + */ + public MethodBuilder addMethod(String methodName) { + return new MethodBuilder(this, methodName); + } + + /** + * Add a field to this type. + */ + public SyntheticTypeBuilder addField(String fieldName, String typeName, String doc) { + fields.add(new SyntheticField(fieldName, typeName, doc, false)); + return this; + } + + /** + * Add a static field to this type. + */ + public SyntheticTypeBuilder addStaticField(String fieldName, String typeName, String doc) { + fields.add(new SyntheticField(fieldName, typeName, doc, true)); + return this; + } + + void addBuiltMethod(SyntheticMethod method) { + methods.add(method); + } + + /** + * Build the synthetic type. + */ + public SyntheticType build() { + return new SyntheticType(name, documentation, methods, fields); + } + + // ==================== Inner classes ==================== + + /** + * Builder for methods within a synthetic type. + */ + public static class MethodBuilder { + private final SyntheticTypeBuilder parent; + private final String name; + private final List parameters = new ArrayList<>(); + private String returnType = "void"; + private String documentation; + private boolean isStatic = false; + private ReturnTypeResolver returnTypeResolver; + + MethodBuilder(SyntheticTypeBuilder parent, String name) { + this.parent = parent; + this.name = name; + } + + /** + * Add a parameter to this method. + */ + public MethodBuilder parameter(String paramName, String typeName) { + parameters.add(new SyntheticParameter(paramName, typeName, null)); + return this; + } + + /** + * Add a parameter with documentation. + */ + public MethodBuilder parameter(String paramName, String typeName, String doc) { + parameters.add(new SyntheticParameter(paramName, typeName, doc)); + return this; + } + + /** + * Set the return type. + */ + public MethodBuilder returns(String typeName) { + this.returnType = typeName; + return this; + } + + /** + * Set a dynamic return type resolver. + * Used for methods like Java.type() where return type depends on arguments. + */ + public MethodBuilder returnsResolved(ReturnTypeResolver resolver) { + this.returnTypeResolver = resolver; + return this; + } + + /** + * Set method documentation. + */ + public MethodBuilder documentation(String doc) { + this.documentation = doc; + return this; + } + + /** + * Mark this method as static. + */ + public MethodBuilder asStatic() { + this.isStatic = true; + return this; + } + + /** + * Finish building this method and return to the type builder. + */ + public SyntheticTypeBuilder done() { + parent.addBuiltMethod(new SyntheticMethod(name, returnType, parameters, documentation, isStatic, returnTypeResolver)); + return parent; + } + } + + /** + * Functional interface for resolving return types dynamically. + */ + @FunctionalInterface + public interface ReturnTypeResolver { + /** + * Resolve the return type given the arguments passed to the method. + * @param arguments The string arguments (for methods like Java.type("className")) + * @return The resolved TypeInfo, or null if cannot be resolved + */ + TypeInfo resolve(String[] arguments); + } + + // ==================== Data classes ==================== + + // ==================== SyntheticType (the result) ==================== +} diff --git a/src/main/java/noppes/npcs/client/key/KeyPreset.java b/src/main/java/noppes/npcs/client/key/KeyPreset.java index e76cf14ff..57ef067e1 100644 --- a/src/main/java/noppes/npcs/client/key/KeyPreset.java +++ b/src/main/java/noppes/npcs/client/key/KeyPreset.java @@ -94,10 +94,8 @@ public void tick() { int keyCode = keyCode(); if (keyCode == -1 || keyCode == 0) return; - - boolean isDown = isMouseKey() ? Mouse.isButtonDown(keyCode + 100) : Keyboard.isKeyDown(keyCode); - isDown = isDown && (isCtrlKeyDown() == hasCtrl()) && (isAltKeyDown() == hasAlt()) && (isShiftKeyDown() == hasShift()); - setDown(isDown); + + setDown(currentState.isDown()); } private static final long SHORT_PRESS_MS = 250L; @@ -211,6 +209,11 @@ public void writeTo(KeyState state) { state.setState(keyCode, hasCtrl, hasAlt, hasShift); } + public boolean isDown() { + boolean isDown = isMouseKey() ? Mouse.isButtonDown(keyCode + 100) : Keyboard.isKeyDown(keyCode); + return isDown && (isCtrlKeyDown() == hasCtrl) && (isAltKeyDown() == hasAlt) && (isShiftKeyDown() == hasShift); + } + public boolean hasState() { return keyCode != -1; } @@ -248,6 +251,19 @@ public void readFromNbt(NBTTagCompound compound) { this.hasAlt = compound.getBoolean("hasAlt"); } + public boolean matches(int keycode, boolean checkModifiers) { + if (!checkModifiers) + return this.keyCode == keycode; + + return this.keyCode == keycode && this.hasCtrl == KeyPreset.isCtrlKeyDown() + && this.hasAlt == KeyPreset.isAltKeyDown() + && this.hasShift == KeyPreset.isShiftKeyDown(); + } + + public boolean isMouseKey() { + return keyCode < -1; + } + public String getName() { int code = keyCode; String name = ""; diff --git a/src/main/java/noppes/npcs/client/key/KeyPresetManager.java b/src/main/java/noppes/npcs/client/key/KeyPresetManager.java index 1a978cdc7..df68eb812 100644 --- a/src/main/java/noppes/npcs/client/key/KeyPresetManager.java +++ b/src/main/java/noppes/npcs/client/key/KeyPresetManager.java @@ -24,6 +24,15 @@ public KeyPreset add(String name) { return preset; } + public boolean hasMatchingKeyPressed(int keyCode) { + for (KeyPreset key : keys) { + if (key.currentState.matches(keyCode, true)) + return true; + } + + return false; + } + public void tick() { for (KeyPreset key : keys) key.tick(); diff --git a/src/main/java/noppes/npcs/client/key/impl/ScriptEditorKeys.java b/src/main/java/noppes/npcs/client/key/impl/ScriptEditorKeys.java index 4a9e294d0..3029d4424 100644 --- a/src/main/java/noppes/npcs/client/key/impl/ScriptEditorKeys.java +++ b/src/main/java/noppes/npcs/client/key/impl/ScriptEditorKeys.java @@ -18,6 +18,11 @@ public class ScriptEditorKeys extends KeyPresetManager { public final KeyPreset FORMAT = add("Format Code").setDefaultState(Keyboard.KEY_F, false, true, false); public final KeyPreset TOGGLE_COMMENT = add("Toggle Comment").setDefaultState(Keyboard.KEY_SLASH, true, false, false); + // Editing + public final KeyPreset DELETE_LINE = add("Delete Line").setDefaultState(Keyboard.KEY_DELETE, true, false, false); + public final KeyPreset MOVE_LINE_UP = add("Move Line Up").setDefaultState(Keyboard.KEY_UP, false, true, false); + public final KeyPreset MOVE_LINE_DOWN = add("Move Line Down").setDefaultState(Keyboard.KEY_DOWN, false, true, false); + // Search/Replace public final KeyPreset SEARCH = add("Search").setDefaultState(Keyboard.KEY_F, true, false, false); public final KeyPreset SEARCH_REPLACE = add("Replace").setDefaultState(Keyboard.KEY_F, true, false, true); @@ -30,6 +35,9 @@ public class ScriptEditorKeys extends KeyPresetManager { // View public final KeyPreset FULLSCREEN = add("Toggle Fullscreen").setDefaultState(Keyboard.KEY_F11, false, false, false); + + // Autocomplete + public final KeyPreset AUTOCOMPLETE = add("Autocomplete").setDefaultState(Keyboard.KEY_SPACE, true, false, false); public ScriptEditorKeys() { super("script_editor"); diff --git a/src/main/java/noppes/npcs/controllers/HookDefinition.java b/src/main/java/noppes/npcs/controllers/HookDefinition.java index 864e37ee3..be6c09397 100644 --- a/src/main/java/noppes/npcs/controllers/HookDefinition.java +++ b/src/main/java/noppes/npcs/controllers/HookDefinition.java @@ -148,9 +148,9 @@ public static HookDefinition fromMethod(String hookName, Method method) { } // Build required imports from event type - String importName = getImportForClass(eventType); - if (importName != null) { - builder.requiredImports(importName); + String[] importNames = getImportsForClass(eventType); + if (importNames.length > 0) { + builder.requiredImports(importNames); } } @@ -185,26 +185,31 @@ public static HookDefinition fromMethod(Method method) { } /** - * Get the import statement needed for a class. - * For nested classes, returns the enclosing class. + * Get the import statements needed for a class. + * For nested classes, includes both enclosing and nested names. */ - private static String getImportForClass(Class clazz) { + private static String[] getImportsForClass(Class clazz) { if (clazz == null || clazz.isPrimitive()) { - return null; + return new String[0]; + } + + String fullName = clazz.getName(); + if (fullName.startsWith("java.lang.")) { + return new String[0]; } - // For nested classes, get the top-level enclosing class Class enclosing = clazz; while (enclosing.getEnclosingClass() != null) { enclosing = enclosing.getEnclosingClass(); } - String name = enclosing.getName(); - if (name.startsWith("java.lang.")) { - return null; + String enclosingName = enclosing.getName(); + String nestedName = fullName.replace('$', '.'); + if (!nestedName.equals(enclosingName)) { + return new String[]{enclosingName, nestedName}; } - return name; + return new String[]{enclosingName}; } // ==================== Builder ==================== @@ -231,9 +236,9 @@ public Builder eventClass(Class clazz) { if (clazz != null) { this.eventClassName = clazz.getName(); - String importName = getImportForClass(clazz); - if (importName != null) { - this.requiredImports = new String[]{importName}; + String[] importNames = getImportsForClass(clazz); + if (importNames.length > 0) { + this.requiredImports = importNames; } if (clazz.isAnnotationPresent(Cancelable.class)) { diff --git a/src/main/java/noppes/npcs/controllers/data/DataScript.java b/src/main/java/noppes/npcs/controllers/data/DataScript.java index b112bf470..7d9fb5c3a 100644 --- a/src/main/java/noppes/npcs/controllers/data/DataScript.java +++ b/src/main/java/noppes/npcs/controllers/data/DataScript.java @@ -20,6 +20,7 @@ import noppes.npcs.constants.ScriptContext; import noppes.npcs.controllers.ScriptContainer; import noppes.npcs.controllers.ScriptController; +import noppes.npcs.controllers.ScriptHookController; import noppes.npcs.entity.EntityNPCInterface; import noppes.npcs.janino.EventJaninoScript; import noppes.npcs.scripted.NpcAPI; @@ -28,15 +29,10 @@ import noppes.npcs.scripted.constants.JobType; import noppes.npcs.scripted.constants.RoleType; import noppes.npcs.scripted.entity.ScriptNpc; +import noppes.npcs.api.handler.IHookDefinition; import javax.script.ScriptEngine; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; +import java.util.*; public class DataScript implements IScriptHandlerPacket { public List eventScripts = new ArrayList<>(); @@ -56,6 +52,17 @@ public class DataScript implements IScriptHandlerPacket { public boolean aiNeedsUpdate = false; public boolean hasInited = false; + // Editor/runtime globals descriptor used for a single source of truth. + private static final class GlobalDefinition { + private final String typeName; + private final Object value; + + private GlobalDefinition(String typeName, Object value) { + this.typeName = typeName; + this.value = value; + } + } + public DataScript(EntityNPCInterface npc) { for (int i = 0; i < 15; i++) { this.setNPCScript(i, new ScriptContainer(this)); @@ -180,21 +187,12 @@ public boolean callScript(EnumScriptType type, Event event, Object... obs) { LogWriter.postScriptLog(npc.field_110179_h, type, String.format("[%s] NPC %s (%s, %s, %s) | Objects: %s", ((String) type.function).toUpperCase(), npc.display.name, (int) npc.posX, (int) npc.posY, (int) npc.posZ, Arrays.toString(obs))); } } - return callScript(script, event); + return callScript(script, type.function, event); } - private boolean callScript(ScriptContainer script, Event event) { + private boolean callScript(ScriptContainer script, String hookName, Event event) { ScriptEngine engine = script.engine; - engine.put("npc", dummyNpc); - engine.put("world", dummyWorld); - engine.put("event", event); - engine.put("API", NpcAPI.Instance()); - engine.put("EntityType", entities); - engine.put("RoleType", roles); - engine.put("JobType", jobs); - for (Map.Entry engineObjects : NpcAPI.engineObjects.entrySet()) { - engine.put(engineObjects.getKey(), engineObjects.getValue()); - } + applyGlobalsToEngine(engine, hookName, event); script.run(engine); if (clientNeedsUpdate) { @@ -254,6 +252,61 @@ public void callScript(String hookName, Event event) { } } + public Map getEditorGlobals(String hookName) { + Map definitions = getGlobalDefinitions(hookName, null); + Map globals = new LinkedHashMap<>(); + for (Map.Entry entry : definitions.entrySet()) { + String typeName = entry.getValue().typeName; + if (typeName != null && !typeName.isEmpty()) { + globals.put(entry.getKey(), typeName); + } + } + return globals; + } + + // Apply runtime globals plus engine-only bindings. + private void applyGlobalsToEngine(ScriptEngine engine, String hookName, Event event) { + Map definitions = getGlobalDefinitions(hookName, event); + for (Map.Entry entry : definitions.entrySet()) { + engine.put(entry.getKey(), entry.getValue().value); + } + + engine.put("API", NpcAPI.Instance()); + for (Map.Entry engineObjects : NpcAPI.engineObjects.entrySet()) { + engine.put(engineObjects.getKey(), engineObjects.getValue()); + } + } + + // Build shared globals (editor types + runtime values). + private Map getGlobalDefinitions(String hookName, Event event) { + Map globals = new LinkedHashMap<>(); + globals.put("npc", new GlobalDefinition("ICustomNpc", dummyNpc)); + globals.put("world", new GlobalDefinition("IWorld", dummyWorld)); + globals.put("event", new GlobalDefinition(resolveEditorEventTypeName(hookName), event)); + globals.put("EntityType", new GlobalDefinition("noppes.npcs.scripted.constants.EntityType", entities)); + globals.put("RoleType", new GlobalDefinition("noppes.npcs.scripted.constants.RoleType", roles)); + globals.put("JobType", new GlobalDefinition("noppes.npcs.scripted.constants.JobType", jobs)); + + return globals; + } + + // Resolve editor-only event type from hook name with a single fallback. + private String resolveEditorEventTypeName(String hookName) { + final String fallback = "INpcEvent"; + if (hookName == null || hookName.isEmpty()) + return fallback; + + IHookDefinition definition = ScriptHookController.Instance.getHookDefinition(getContext().hookContext, hookName); + if (definition == null) + return fallback; + + String typeName = definition.getUsableTypeName(); + if (typeName == null || typeName.isEmpty()) + return fallback; + + return typeName; + } + public boolean isClient() { return this.npc.isRemote(); } diff --git a/src/main/java/noppes/npcs/janino/JaninoScript.java b/src/main/java/noppes/npcs/janino/JaninoScript.java index 2afe8052f..0014bccea 100644 --- a/src/main/java/noppes/npcs/janino/JaninoScript.java +++ b/src/main/java/noppes/npcs/janino/JaninoScript.java @@ -21,14 +21,7 @@ import java.lang.reflect.Modifier; import java.security.Permissions; import java.security.PrivilegedAction; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; +import java.util.*; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -49,7 +42,9 @@ public abstract class JaninoScript implements IScriptUnit { private final JaninoHookResolver hookResolver = new JaninoHookResolver(); private final String[] defaultImports; - + // Cache of imports used in the last compilation + private String[] cachedImports; + private Map hookDefCache; private int lastHookRevision = -1; private int lastSeenGlobalRevision; @@ -128,8 +123,7 @@ public void unload() { public void compileScript(String code) { try { - String[] imports = collectImportsForCode(code); - builder.setDefaultImports(imports); + builder.setDefaultImports(cachedImports = collectImportsForCode(code)); this.scriptBody = builder.build(); scriptBody.setScript(code); } catch (InternalCompilerException e) { @@ -396,6 +390,56 @@ private static String getUsableTypeName(Class type) { return sb.toString(); } + /** + * Get the default imports configured for this script type. + * These are packages/classes that are automatically available without explicit import statements. + * + * @return Array of default import patterns (e.g., "noppes.npcs.api.*") + */ + public String[] getDefaultImports() { + return defaultImports; + } + + private String[] getCachedImports() { + if (cachedImports == null) { + cachedImports = collectImportsForCode(getFullCode()); + } + return cachedImports; + } + + /** + * Get all types used in hook method signatures (parameters and return types). + * This includes event types like INpcEvent.InitEvent, INpcEvent.DamagedEvent, etc., + * as well as return types like Color, String, etc. + * Useful for syntax highlighting to know what types are implicitly available. + * + * @return Set of fully qualified class names for all hook parameter and return types + */ + public Set getHookTypes() { + Set types = new HashSet<>(); + Collections.addAll(types, getCachedImports()); + return types; + } + + /** + * Add a type and all its enclosing types to the set. + * For nested classes like INpcEvent.InitEvent, this adds both INpcEvent and INpcEvent$InitEvent. + */ + private void addTypeAndEnclosingTypes(Set types, Class clazz) { + if (clazz == null || clazz.isPrimitive()) + return; + + // Add the type itself + types.add(clazz.getName()); + + // Add enclosing/declaring class if it's a nested type + Class enclosing = clazz.getDeclaringClass(); + while (enclosing != null) { + types.add(enclosing.getName()); + enclosing = enclosing.getDeclaringClass(); + } + } + // ==================== IScriptUnit ==================== @Override @@ -407,6 +451,7 @@ public String getScript() { public void setScript(String script) { this.script = script; this.evaluated = false; + this.cachedImports = null; hookResolver.clearResolutionCaches(); } @@ -419,6 +464,7 @@ public List getExternalScripts() { public void setExternalScripts(List scripts) { this.externalScripts = scripts; this.evaluated = false; + this.cachedImports = null; hookResolver.clearResolutionCaches(); } diff --git a/src/main/resources/assets/customnpcs/textures/gui/script/icons.png b/src/main/resources/assets/customnpcs/textures/gui/script/icons.png new file mode 100644 index 000000000..4e19870f3 Binary files /dev/null and b/src/main/resources/assets/customnpcs/textures/gui/script/icons.png differ