diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index 55962bb24..cef253c07 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -8,6 +8,8 @@ import org.cyclops.integrateddynamics.api.evaluate.EvaluationException; import org.cyclops.integratedscripting.api.network.IScriptFactory; +import javax.annotation.Nullable; +import java.nio.file.Path; import java.util.List; /** @@ -27,6 +29,17 @@ public interface ILanguageHandler { public IScriptFactory getScriptFactory() throws EvaluationException; + /** + * Check a script for syntax errors, without executing it. + * @param path The script path. + * @param script The script contents. + * @return The first syntax error, or null if there is none or if checking is not supported. + */ + @Nullable + public default ScriptSyntaxError checkSyntax(Path path, String script) { + return null; + } + public void onServerStarted(); } diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java new file mode 100644 index 000000000..b41bb28a3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java @@ -0,0 +1,11 @@ +package org.cyclops.integratedscripting.api.language; + +/** + * A syntax error in a script. + * @param message The error message. + * @param line The 1-based line of the error. + * @param column The 1-based column of the error. + * @author rubensworks + */ +public record ScriptSyntaxError(String message, int line, int column) { +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 152c70c06..e664e42a1 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,6 +39,7 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.client.key.Keys; import org.lwjgl.glfw.GLFW; @@ -107,6 +108,8 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private String indent = " "; @Nullable private ShortcutListener shortcutListener; + @Nullable + private ScriptSyntaxError syntaxError; public WidgetTextArea(Font font, int x, int y, int width, int height, Component narrationMessage, boolean scrollBar, boolean showLineNumbers) { super(font, x, y, width, height, narrationMessage); @@ -208,6 +211,10 @@ public void selectRange(int start, int end) { this.ensureCursorVisible(); } + public void setSyntaxError(@Nullable ScriptSyntaxError syntaxError) { + this.syntaxError = syntaxError; + } + /** * @param indent The string that is inserted for one indentation level. */ @@ -747,6 +754,16 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float } } + // Mark the gutter of the line with a syntax error + if (this.syntaxError != null && this.showLineNumbers) { + for (LineInfo line : lines) { + if (line.lineNumber + 1 == this.syntaxError.line()) { + // Fill the space between the editor border and the gutter separator + guiGraphics.fill(this.getX() - 1, line.y - 1, this.getX() + getLinesXOffset() - 2, line.y + ROW_HEIGHT - 1, Helpers.RGBAToInt(255, 160, 160, 255)); + } + } + } + // Draw lines int lastLineNumber = -1; for(LineInfo line : lines) { @@ -760,7 +777,7 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float this.getX(), line.y - offsetY + 2, 0.5f, - line.hasCursor ? 0 : Helpers.RGBToInt(120, 120, 120), + this.syntaxError != null && this.syntaxError.line() == line.lineNumber + 1 ? Helpers.RGBToInt(170, 0, 0) : (line.hasCursor ? 0 : Helpers.RGBToInt(120, 120, 120)), false, Font.DisplayMode.NORMAL ); @@ -768,6 +785,10 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float lastLineNumber = line.lineNumber; } + if (this.syntaxError != null) { + this.renderSyntaxError(guiGraphics, displayCache, lines, mouseX, mouseY); + } + // Show highlighting and cursor this.renderHighlight(displayCache.selection); if (displayCache.cursor != null) { @@ -777,6 +798,36 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float // Render scrollbar if (this.scrollBar != null) { this.scrollBar.render(guiGraphics, getX(), getY(), partialTicks); + + // Mark the position of the syntax error on the scrollbar, so it can be found when scrolled out of view + if (this.syntaxError != null && displayCache.linesTotal > this.scrollBar.getVisibleRows()) { + int errorRow = findLineFromPos(displayCache.lineStarts, TextEditing.lineOffset(this.getValue(), this.syntaxError.line())); + int y = this.getY() + (int) ((long) errorRow * (this.getHeight() - 2) / displayCache.linesTotal); + int x = this.getX() + this.getWidth() - SCROLLBAR_WIDTH + 1; + guiGraphics.fill(x, y, x + SCROLLBAR_WIDTH - 2, y + 2, Helpers.RGBAToInt(220, 0, 0, 255)); + } + } + } + + private void renderSyntaxError(GuiGraphics guiGraphics, DisplayCache displayCache, List lines, int mouseX, int mouseY) { + String value = this.getValue(); + // Underline the character at the error position + int pos = Math.min(value.length(), TextEditing.lineOffset(value, this.syntaxError.line()) + Math.max(0, this.syntaxError.column() - 1)); + int line = findLineFromPos(displayCache.lineStarts, pos); + int row = line - this.firstRow; + if (row >= 0 && row < this.getHeight() / ROW_HEIGHT) { + int x = this.font.width(value.substring(displayCache.lineStarts[line], pos)); + int width = pos < value.length() && value.charAt(pos) != '\n' ? this.font.width(value.substring(pos, pos + 1)) : 4; + Pos2i screenPos = this.convertLocalToScreen(new Pos2i(x, row * ROW_HEIGHT)); + guiGraphics.fill(screenPos.x, screenPos.y + ROW_HEIGHT - 2, screenPos.x + width, screenPos.y + ROW_HEIGHT - 1, Helpers.RGBAToInt(220, 0, 0, 255)); + } + + // Show the message when hovering the error line + for (LineInfo lineInfo : lines) { + if (lineInfo.lineNumber + 1 == this.syntaxError.line() && mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() + && mouseY >= lineInfo.y && mouseY < lineInfo.y + ROW_HEIGHT && Minecraft.getInstance().screen != null) { + Minecraft.getInstance().screen.setTooltipForNextRenderPass(this.font.split(Component.literal(this.syntaxError.message()), 200)); + } } } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index d1ef3cc7e..37c568c44 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -152,6 +152,11 @@ public int getTotalRows() { return false; }); getMenu().setRemoteScriptChangeListener(this::onRemoteScriptChanged); + getMenu().setSyntaxErrorListener((disk, path) -> { + if (disk == getMenu().getActiveDisk() && path.equals(getMenu().getActiveScriptPath())) { + this.textArea.setSyntaxError(getMenu().getSyntaxError(disk, path)); + } + }); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); textArea.setMarkupProvider((style, line) -> { @@ -544,6 +549,7 @@ private void onActiveScriptSelected() { String script = getMenu().getActiveScript(); this.textArea.setValue(script == null ? "" : script); Path path = getMenu().getActiveScriptPath(); + this.textArea.setSyntaxError(path == null ? null : getMenu().getSyntaxError(getMenu().getActiveDisk(), path)); this.textArea.setHistory(path == null ? new TextHistory() : scriptHistories.computeIfAbsent(Pair.of(getMenu().getActiveDisk(), path), k -> new TextHistory())); } diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index a5ce5568a..26777eb70 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -12,9 +12,17 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.api.network.IScriptFactory; import org.cyclops.integratedscripting.client.gui.image.ScriptImages; import org.cyclops.integratedscripting.core.network.GraalScriptFactory; +import org.cyclops.integratedscripting.evaluate.ScriptHelpers; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.SourceSection; + +import javax.annotation.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -115,6 +123,27 @@ public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; } + @Nullable + @Override + public ScriptSyntaxError checkSyntax(Path path, String script) { + try (Context context = ScriptHelpers.createBaseContext(null)) { + // Parsing doesn't execute the script + context.parse(Source.newBuilder("js", script, path.toString()).build()); + return null; + } catch (PolyglotException e) { + if (!e.isSyntaxError()) { + return null; + } + SourceSection location = e.getSourceLocation(); + // Only keep the first line, as the following lines contain a source excerpt + return new ScriptSyntaxError(e.getMessage().split("\n", 2)[0], + location != null && location.hasLines() ? location.getStartLine() : 1, + location != null && location.hasColumns() ? location.getStartColumn() : 1); + } catch (IOException e) { + return null; + } + } + @Override public void onServerStarted() { this.generateTypeScriptTypes(); diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index bc637de19..47e87e303 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -18,6 +18,7 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; import org.cyclops.cyclopscore.helper.ValueNotifierHelpers; import org.cyclops.cyclopscore.inventory.SimpleInventory; import org.cyclops.cyclopscore.inventory.container.InventoryContainer; @@ -33,14 +34,18 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.RegistryEntries; import org.cyclops.integratedscripting.api.item.IScriptVariableFacade; +import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.api.network.IScriptingData; import org.cyclops.integratedscripting.api.network.IScriptingNetwork; import org.cyclops.integratedscripting.core.evaluate.ScriptVariableFacadeHandler; import org.cyclops.integratedscripting.core.item.ScriptVariableFacade; +import org.cyclops.integratedscripting.core.language.LanguageHandlers; import org.cyclops.integratedscripting.core.network.ScriptingNetworkHelpers; import org.cyclops.integratedscripting.network.packet.TerminalScriptingCreateNewScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingSyntaxErrorPacket; import org.cyclops.integratedscripting.part.PartTypeTerminalScripting; import javax.annotation.Nullable; @@ -55,6 +60,8 @@ */ public class ContainerTerminalScripting extends InventoryContainer implements IDirtyMarkListener { + public static final int SYNTAX_CHECK_DELAY_TICKS = 10; + public static Pattern INVALID_MEMBER_NAME = Pattern.compile("[^0-9a-zA-Z_]"); public static Pattern VALID_MEMBER_NAME = Pattern.compile("[0-9a-zA-Z_]+"); @@ -73,6 +80,14 @@ public class ContainerTerminalScripting extends InventoryContainer implements ID private final int selectionId; @Nullable private BiConsumer remoteScriptChangeListener; + private final Map, ScriptSyntaxError> syntaxErrors = Maps.newHashMap(); + @Nullable + private BiConsumer syntaxErrorListener; + @Nullable + private Triple syntaxCheckPending; + @Nullable + private Triple syntaxCheckDone; + private int syntaxCheckTicks; public ContainerTerminalScripting(int id, Inventory playerInventory, RegistryFriendlyByteBuf packetBuffer) { this(id, playerInventory, PartHelpers.readPartTarget(packetBuffer), Optional.empty(), @@ -216,7 +231,58 @@ public void broadcastChanges() { } } }); + + this.checkActiveScriptSyntax(); + } + } + + /** + * Check the active script for syntax errors once it hasn't changed for a while, and send the result to the client. + */ + protected void checkActiveScriptSyntax() { + Path path = getActiveScriptPath(); + int disk = getActiveDisk(); + if (path == null || disk < 0) { + return; + } + String script = ScriptingNetworkHelpers.getScriptingData().getScripts(disk).get(path); + if (script == null) { + return; } + Triple key = Triple.of(disk, path, script); + if (!key.equals(this.syntaxCheckPending)) { + this.syntaxCheckPending = key; + this.syntaxCheckTicks = 0; + return; + } + if (key.equals(this.syntaxCheckDone) || ++this.syntaxCheckTicks < SYNTAX_CHECK_DELAY_TICKS) { + return; + } + this.syntaxCheckDone = key; + ILanguageHandler languageHandler = LanguageHandlers.REGISTRY.getProvider(path); + ScriptSyntaxError error = languageHandler == null ? null : languageHandler.checkSyntax(path, script); + IntegratedScripting._instance.getPacketHandler() + .sendToPlayer(new TerminalScriptingSyntaxErrorPacket(disk, path, error), (ServerPlayer) player); + } + + public void setSyntaxErrorListener(@Nullable BiConsumer syntaxErrorListener) { + this.syntaxErrorListener = syntaxErrorListener; + } + + public void setSyntaxError(int disk, Path path, @Nullable ScriptSyntaxError error) { + if (error == null) { + this.syntaxErrors.remove(Pair.of(disk, path)); + } else { + this.syntaxErrors.put(Pair.of(disk, path), error); + } + if (this.syntaxErrorListener != null) { + this.syntaxErrorListener.accept(disk, path); + } + } + + @Nullable + public ScriptSyntaxError getSyntaxError(int disk, Path path) { + return this.syntaxErrors.get(Pair.of(disk, path)); } @Override diff --git a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java new file mode 100644 index 000000000..eacb71ecc --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java @@ -0,0 +1,71 @@ +package org.cyclops.integratedscripting.network.packet; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.cyclopscore.network.CodecField; +import org.cyclops.cyclopscore.network.PacketCodec; +import org.cyclops.integratedscripting.Reference; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; +import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScripting; + +import javax.annotation.Nullable; +import java.nio.file.Path; + +/** + * Packet for sending the syntax error of a script from server to client. + * @author rubensworks + * + */ +public class TerminalScriptingSyntaxErrorPacket extends PacketCodec { + + public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "terminal_scripting_syntax_error")); + public static final StreamCodec CODEC = getCodec(TerminalScriptingSyntaxErrorPacket::new); + + @CodecField + private int disk; + @CodecField + private String path; + @CodecField + private String message; + @CodecField + private int line; + @CodecField + private int column; + + public TerminalScriptingSyntaxErrorPacket() { + super(ID); + } + + public TerminalScriptingSyntaxErrorPacket(int disk, Path path, @Nullable ScriptSyntaxError error) { + super(ID); + this.disk = disk; + this.path = path.toString(); + this.message = error == null ? "" : error.message(); + this.line = error == null ? 0 : error.line(); + this.column = error == null ? 0 : error.column(); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + @OnlyIn(Dist.CLIENT) + public void actionClient(Level world, Player player) { + if(player.containerMenu instanceof ContainerTerminalScripting container) { + container.setSyntaxError(disk, Path.of(path), message.isEmpty() ? null : new ScriptSyntaxError(message, line, column)); + } + } + + @Override + public void actionServer(Level world, ServerPlayer player) { + + } +} diff --git a/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java b/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java index 826341979..9f579a612 100644 --- a/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java +++ b/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java @@ -7,6 +7,7 @@ import org.cyclops.integratedscripting.network.packet.TerminalScriptingCreateNewScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingSyntaxErrorPacket; /** * Proxy for server and client side. @@ -27,5 +28,6 @@ public void registerPacketHandlers(PacketHandler packetHandler) { packetHandler.register(TerminalScriptingModifiedScriptPacket.ID, TerminalScriptingModifiedScriptPacket.CODEC); packetHandler.register(TerminalScriptingCreateNewScriptPacket.ID, TerminalScriptingCreateNewScriptPacket.CODEC); packetHandler.register(TerminalScriptingDeleteScriptPacket.ID, TerminalScriptingDeleteScriptPacket.CODEC); + packetHandler.register(TerminalScriptingSyntaxErrorPacket.ID, TerminalScriptingSyntaxErrorPacket.CODEC); } } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 678959996..fb5cc62ba 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -129,7 +129,7 @@ "info_book.integratedscripting.terminal.text4": "If the disk does not contain any script files yet, you can create them by clicking on the \"+\"-button on the bottom-left side of the screen.", "info_book.integratedscripting.terminal.text5": "The script file list on the left-hand side of the screen is used to select a script for editing. When clicking on a script, its editable contents will show up on the right-hand-side of the screen.", "info_book.integratedscripting.terminal.text6": "The text field on the right-hand side of the screen allows you to edit the contents of script files, which will be saved automatically after each modification.", - "info_book.integratedscripting.terminal.text7": "While the editor will perform limited syntax highlighting, it is not able to detect syntax errors at this time. Syntax errors will be reported when executing scripts via &lVariable Cards&r.", + "info_book.integratedscripting.terminal.text7": "The editor performs limited syntax highlighting, and marks the line of the first syntax error in red. Hover over that line to see the error message.", "info_book.integratedscripting.terminal.text8": "In order to use a script member as &lIntegrated Dynamics&r logic, you can bind it to a &lVariable Card&r.", "info_book.integratedscripting.terminal.text9": "You can do this by first selecting a script member in the text editor by selecting it with your cursor, such as a variable name or function name.", "info_book.integratedscripting.terminal.text10": "After that, you can insert a &lVariable Card&r into the slot on the right-hand side of the screen to bind it to this script member.", diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java new file mode 100644 index 000000000..87fb24ef0 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java @@ -0,0 +1,43 @@ +package org.cyclops.integratedscripting.core.language; + +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; +import org.junit.Test; + +import java.nio.file.Path; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + +public class LanguageHandlerJavaScriptSyntaxTest { + + private static ScriptSyntaxError check(String script) { + return new LanguageHandlerJavaScript().checkSyntax(Path.of("script0.js"), script); + } + + @Test + public void testValid() { + assertThat(check("const a = 1;\nfunction f(b) { return a + b; }"), nullValue()); + } + + @Test + public void testValidDoesNotExecute() { + assertThat(check("throw new Error('runtime');"), nullValue()); + } + + @Test + public void testSyntaxError() { + ScriptSyntaxError error = check("const a = 1;\nconst = ;"); + assertThat(error.line(), equalTo(2)); + assertThat(error.column(), equalTo(7)); + assertTrue(error.message(), error.message().contains("SyntaxError")); + } + + @Test + public void testUnclosedBracket() { + ScriptSyntaxError error = check("function f() {\n return 1;\n"); + assertTrue(error.message(), error.message().contains("SyntaxError")); + } + +}