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 dad294ad5..2b7a39850 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -56,6 +56,16 @@ public default Set getReservedWords() { return Collections.emptySet(); } + /** + * @param script The script contents. + * @return The names of the members declared at the top level of the script, + * or null if they can not be determined. + */ + @Nullable + public default Set getTopLevelMembers(String script) { + return null; + } + /** * Check a script for syntax errors, without executing it. * @param path The script path. 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 b51c9cb93..ca73cbc7b 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 @@ -61,6 +61,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -758,6 +759,15 @@ private void onSelectionModified() { startPos = value.length() - matcherToStart.end() + 1; } selected = value.substring(startPos, endPos); + + // Only bind to declarations, so moving through function bodies doesn't rebind the card + Path path = getMenu().getActiveScriptPath(); + ILanguageHandler languageHandler = path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; + Set members = languageHandler != null ? languageHandler.getTopLevelMembers(value) : null; + if (members != null && !members.contains(selected)) { + String previous = getMenu().getSelectedMember(); + selected = members.contains(previous) ? previous : ""; + } } } diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java new file mode 100644 index 000000000..9ed54f34a --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java @@ -0,0 +1,119 @@ +package org.cyclops.integratedscripting.core.language; + +import com.google.common.collect.Sets; + +import javax.annotation.Nullable; +import java.util.Set; + +/** + * Finds the names declared at the top level of a JavaScript script, without executing or fully parsing it. + * @author rubensworks + */ +public class JavaScriptDeclarations { + + // Characters after which a slash starts a regex instead of a division + private static final String REGEX_PRECEDERS = "(,=:[!&|?{};+-*%<>~^"; + // Characters after which a function or class is an expression instead of a declaration + private static final String EXPRESSION_PRECEDERS = "(,=:[!&|?+-*%<>~^"; + + /** + * @param script A script. + * @return The names of the top-level functions, classes and variables, + * or null if the script contains declarations that are not supported, such as destructuring. + */ + @Nullable + public static Set find(String script) { + Set names = Sets.newHashSet(); + int depth = 0; + int length = script.length(); + // The last significant character, to detect member accesses and regexes + char previous = ';'; + String previousWord = ""; + // If a name is expected next, after a declaration keyword or a comma in a variable declaration + boolean expectName = false; + boolean inVariableDeclaration = false; + int i = 0; + while (i < length) { + char c = script.charAt(i); + if (Character.isWhitespace(c)) { + i++; + } else if (c == '/' && i + 1 < length && script.charAt(i + 1) == '/') { + int end = script.indexOf('\n', i); + i = end < 0 ? length : end; + } else if (c == '/' && i + 1 < length && script.charAt(i + 1) == '*') { + int end = script.indexOf("*/", i + 2); + i = end < 0 ? length : end + 2; + } else if (c == '"' || c == '\'' || c == '`' + || (c == '/' && (REGEX_PRECEDERS.indexOf(previous) >= 0 || previousWord.equals("return")))) { + i = skipQuoted(script, i, c); + previous = 'a'; + previousWord = ""; + expectName = false; + } else if (Character.isJavaIdentifierStart(c)) { + int start = i; + while (i < length && Character.isJavaIdentifierPart(script.charAt(i))) { + i++; + } + String word = script.substring(start, i); + if (depth == 0 && previous != '.') { + if (expectName) { + names.add(word); + expectName = false; + } else if ((word.equals("function") || word.equals("class")) + && EXPRESSION_PRECEDERS.indexOf(previous) < 0 && !previousWord.equals("return")) { + expectName = true; + inVariableDeclaration = false; + } else if (word.equals("var") || word.equals("let") || word.equals("const")) { + expectName = true; + inVariableDeclaration = true; + } + } + previous = 'a'; + previousWord = word; + } else { + if (depth == 0) { + if (expectName && (c == '{' || c == '[') && inVariableDeclaration) { + // Destructuring + return null; + } + if (c == ';') { + inVariableDeclaration = false; + } else if (c == ',' && inVariableDeclaration) { + expectName = true; + } else if (c != '*') { + // Allow generator functions + expectName = false; + } + } + if (c == '{' || c == '(' || c == '[') { + depth++; + } else if ((c == '}' || c == ')' || c == ']') && depth > 0) { + depth--; + } + previous = c; + previousWord = ""; + i++; + } + } + return names; + } + + private static int skipQuoted(String script, int start, char quote) { + int i = start + 1; + while (i < script.length()) { + char c = script.charAt(i); + if (c == '\\') { + i += 2; + } else if (c == quote) { + return i + 1; + } else if (c == '\n' && quote != '`') { + // Unterminated + return i; + } else { + i++; + } + } + return i; + } + +} 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 8c4c09c84..0fe44e486 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -168,6 +168,12 @@ public Set getReservedWords() { return RESERVED_WORDS; } + @Nullable + @Override + public Set getTopLevelMembers(String script) { + return JavaScriptDeclarations.find(script); + } + @Override public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java new file mode 100644 index 000000000..8e9cb995a --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java @@ -0,0 +1,53 @@ +package org.cyclops.integratedscripting.core.language; + +import com.google.common.collect.Sets; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +public class JavaScriptDeclarationsTest { + + @Test + public void testDeclarations() { + assertThat(JavaScriptDeclarations.find("function a(x, y) { return x; }\nconst b = 1;\nlet c = 2\nvar d\nclass E {}\nasync function f() {}\nfunction* g() {}\nexport const h = 3;"), + equalTo(Sets.newHashSet("a", "b", "c", "d", "E", "f", "g", "h"))); + } + + @Test + public void testMultipleDeclarators() { + assertThat(JavaScriptDeclarations.find("const a = f(1, 2), b = [3, 4], c = { x: 1, y: 2 };\nfoo(d, e);"), + equalTo(Sets.newHashSet("a", "b", "c"))); + } + + @Test + public void testIgnoresNested() { + assertThat(JavaScriptDeclarations.find("function a(item) {\n const b = item.size();\n function c() {}\n return b;\n}\nconst d = (x) => { let e = x; return e; };"), + equalTo(Sets.newHashSet("a", "d"))); + } + + @Test + public void testIgnoresExpressions() { + assertThat(JavaScriptDeclarations.find("const a = function b() {};\nconst c = class D {};\nfoo(function e() {});"), + equalTo(Sets.newHashSet("a", "c"))); + } + + @Test + public void testIgnoresStringsCommentsAndRegexes() { + assertThat(JavaScriptDeclarations.find("// const a = 1;\n/* function b() {} */\nconst c = \"const d = '{';\";\nconst e = /[{(]/.test(c);\nconst f = `${c} {`;\nconst g = 4 / 2;"), + equalTo(Sets.newHashSet("c", "e", "f", "g"))); + } + + @Test + public void testIgnoresMemberAccesses() { + assertThat(JavaScriptDeclarations.find("const a = idContext.ops.function;"), equalTo(Sets.newHashSet("a"))); + } + + @Test + public void testDestructuringUnsupported() { + assertThat(JavaScriptDeclarations.find("const { a, b } = c;"), nullValue()); + assertThat(JavaScriptDeclarations.find("const [a, b] = c;"), nullValue()); + } + +}