diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterConnection.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterConnection.java index 236d039d62..cc093ba06c 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterConnection.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterConnection.java @@ -45,6 +45,23 @@ public interface InterpreterConnection { * * @param cmd The command to get code completions for * @return A {@link List} of {@link CodeCompletion code completions} for the given command + * @deprecated Additionally implement {@link #getCompletions(String, int)} + * and consider generating completions relative to the caret position */ + @Deprecated public List getCompletions(String cmd); + + /** + * Gets a {@link List} of {@link CodeCompletion code completions} for the given command + * relative to the given caret position. + * + * @param cmd The command to get code completions for + * @param caretPos The position of the caret in the input string 'cmd'. + * It should satisfy the constraint {@literal "0 <= caretPos <= cmd.length()"} + * @return A {@link List} of {@link CodeCompletion code completions} for the given command + */ + public default List getCompletions(String cmd, int caretPos) { + // to preserve backward compatibility with existent implementations + return getCompletions(cmd); + } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterPanel.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterPanel.java index aff3b9932d..bf7f1f7ff1 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterPanel.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/interpreter/InterpreterPanel.java @@ -76,6 +76,7 @@ public class InterpreterPanel extends JPanel implements OptionsChangeListener { private CompletionWindowTrigger completionWindowTrigger = CompletionWindowTrigger.TAB; private boolean highlightCompletion = false; + private int completionInsertionPosition; private boolean caretGuard = true; private PluginTool tool; @@ -482,9 +483,13 @@ public class InterpreterPanel extends JPanel implements OptionsChangeListener { return; } + // We save the position of the caret here in advance because the user can move it + // later (but before the insertion takes place) and make the completions invalid. + completionInsertionPosition = inputTextPane.getCaretPosition(); + String text = getInputTextPaneText(); - List completions = - InterpreterPanel.this.interpreter.getCompletions(text); + List completions = InterpreterPanel.this.interpreter.getCompletions( + text, completionInsertionPosition); completionWindow.updateCompletionList(completions); }); } @@ -620,11 +625,11 @@ public class InterpreterPanel extends JPanel implements OptionsChangeListener { } String text = getInputTextPaneText(); - int position = inputTextPane.getCaretPosition(); + int position = completionInsertionPosition; String insertion = completion.getInsertion(); /* insert completion string */ - int insertedTextStart = position - completion.getCharsToRemove(); + int insertedTextStart = Math.max(0, position - completion.getCharsToRemove()); int insertedTextEnd = insertedTextStart + insertion.length(); String inputText = text.substring(0, insertedTextStart) + insertion + text.substring(position); diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/interpreter/InterpreterPanelTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/interpreter/InterpreterPanelTest.java index f3bd6f404e..9887e24f6f 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/interpreter/InterpreterPanelTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/interpreter/InterpreterPanelTest.java @@ -162,27 +162,35 @@ public class InterpreterPanelTest extends AbstractGhidraHeadedIntegrationTest { // a part of the input near the caret // case 1: "simple." => "simple.completion" - testingCodeCompletions = List.of(new CodeCompletion("test", "completion", null, 0)); - triggerText(inputTextPane, "simple."); - insertFirstCodeCompletion(); - assertEquals("simple.completion", inputTextPane.getText()); - triggerEnter(inputTextPane); + // @formatter:off + doCompletionInsertionTest( + "simple.", // an initial piece of code + null, // a function to call before the popup opens + 0, // the number of characters to delete + null, // a function to call when the popup is opened + "completion", // a completion to insert + "simple.completion"); // an expected result + // @formatter:on // case 2: "simple." => "not.so.simple.completion" - testingCodeCompletions = List.of( - new CodeCompletion("test", "not.so.simple.completion", null, "simple.".length())); - triggerText(inputTextPane, "simple."); - insertFirstCodeCompletion(); - assertEquals("not.so.simple.completion", inputTextPane.getText()); - triggerEnter(inputTextPane); + doCompletionInsertionTest("simple.", null, "simple.".length(), null, + "not.so.simple.completion", "not.so.simple.completion"); // case 3: "( check.both.sides.of )" => "( check.is.ok )" - testingCodeCompletions = - List.of(new CodeCompletion("test", "is.ok", null, "both.sides.of".length())); - triggerText(inputTextPane, "( check.both.sides.of )"); - inputTextPane.setCaretPosition("( check.both.sides.of".length()); - insertFirstCodeCompletion(); - assertEquals("( check.is.ok )", inputTextPane.getText()); + doCompletionInsertionTest("( check.both.sides.of )", + () -> inputTextPane.setCaretPosition("( check.both.sides.of".length()), + "both.sides.of".length(), null, "is.ok", "( check.is.ok )"); + + // case 4: Completions are inserted at the place where they were last updated. + // Moving the caret after that point should not affect the result. + var possibleCaretPositions = List.of(0, "some".length(), "some initial text".length()); + for (int caretPos : possibleCaretPositions) { + doCompletionInsertionTest("some initial text", + () -> inputTextPane.setCaretPosition("some initial".length()), + "initial".length(), + () -> inputTextPane.setCaretPosition(caretPos), "expected", + "some expected text"); + } } private InterpreterPanel createIP() { @@ -304,10 +312,26 @@ public class InterpreterPanelTest extends AbstractGhidraHeadedIntegrationTest { }).start(); } - private void insertFirstCodeCompletion() { + private void doCompletionInsertionTest(String codeBefore, Runnable callBeforePopup, + int charsToRemove, Runnable callWhenPopupOpens, String completion, + String expectedResult) { + CodeCompletion c = new CodeCompletion("t", completion, null, charsToRemove); + testingCodeCompletions = List.of(c); + triggerText(inputTextPane, codeBefore); + if (callBeforePopup != null) { + runSwing(() -> callBeforePopup.run()); + } + + // open the completion popup and insert the first and only suggestion KeyStroke defaultCompletionTrigger = CompletionWindowTrigger.TAB.getKeyStroke(); triggerKey(inputTextPane, defaultCompletionTrigger); triggerActionKey(inputTextPane, 0, KeyEvent.VK_DOWN); + if (callWhenPopupOpens != null) { + runSwing(() -> callWhenPopupOpens.run()); + } + triggerEnter(inputTextPane); + + assertEquals(expectedResult, inputTextPane.getText()); triggerEnter(inputTextPane); } } diff --git a/Ghidra/Features/Python/python-src/jintrospect.py b/Ghidra/Features/Python/python-src/jintrospect.py index 0ffb437501..8501e31d4d 100644 --- a/Ghidra/Features/Python/python-src/jintrospect.py +++ b/Ghidra/Features/Python/python-src/jintrospect.py @@ -166,7 +166,7 @@ def getCallTipJava(command='', locals=None): for args in object.argslist: if args is not None: # for now - tipList.append(str(args.data)) + tipList.append(str(args.method)) # elif callable(object): # argspec = str(object.__call__) # # these don't seem to be very accurate diff --git a/Ghidra/Features/Python/src/main/java/ghidra/python/GhidraPythonInterpreter.java b/Ghidra/Features/Python/src/main/java/ghidra/python/GhidraPythonInterpreter.java index 85053243ee..fd734d00ce 100644 --- a/Ghidra/Features/Python/src/main/java/ghidra/python/GhidraPythonInterpreter.java +++ b/Ghidra/Features/Python/src/main/java/ghidra/python/GhidraPythonInterpreter.java @@ -442,10 +442,18 @@ public class GhidraPythonInterpreter extends InteractiveInterpreter { * * @param cmd The command line. * @param includeBuiltins True if we should include python built-ins; otherwise, false. + * @param caretPos The position of the caret in the input string 'cmd' * @return A list of possible command completions. Could be empty if there aren't any. * @see PythonPlugin#getCompletions */ - List getCommandCompletions(String cmd, boolean includeBuiltins) { + List getCommandCompletions(String cmd, boolean includeBuiltins, int caretPos) { + // At this point the caret is assumed to be positioned right after the value we need to + // complete (example: "[complete.Me, rest, code]"). To make the completion work + // in our case, it's sufficient (albeit naive) to just remove the text on the right side + // of our caret. The later code (on the python's side) will parse the rest properly + // and will generate the completions. + cmd = cmd.substring(0, caretPos); + if ((cmd.length() > 0) && (cmd.charAt(cmd.length() - 1) == '(')) { return getMethodCommandCompletions(cmd); } diff --git a/Ghidra/Features/Python/src/main/java/ghidra/python/PythonPlugin.java b/Ghidra/Features/Python/src/main/java/ghidra/python/PythonPlugin.java index c260b490a9..0dcbe75b7c 100644 --- a/Ghidra/Features/Python/src/main/java/ghidra/python/PythonPlugin.java +++ b/Ghidra/Features/Python/src/main/java/ghidra/python/PythonPlugin.java @@ -263,6 +263,18 @@ public class PythonPlugin extends ProgramPlugin */ @Override public List getCompletions(String cmd) { + return getCompletions(cmd, cmd.length()); + } + + /** + * Returns a list of possible command completion values at the given position. + * + * @param cmd current command line (without prompt) + * @param caretPos The position of the caret in the input string 'cmd' + * @return A list of possible command completion values. Could be empty if there aren't any. + */ + @Override + public List getCompletions(String cmd, int caretPos) { // Refresh the environment interactiveScript.setSourceFile(new ResourceFile(new File("python"))); interactiveScript.set( @@ -270,7 +282,7 @@ public class PythonPlugin extends ProgramPlugin currentSelection, currentHighlight), interactiveTaskMonitor, console.getOutWriter()); - return interpreter.getCommandCompletions(cmd, includeBuiltins); + return interpreter.getCommandCompletions(cmd, includeBuiltins, caretPos); } @Override diff --git a/Ghidra/Features/Python/src/test.slow/java/ghidra/python/PythonCodeCompletionTest.java b/Ghidra/Features/Python/src/test.slow/java/ghidra/python/PythonCodeCompletionTest.java index 75bbdae580..7e155cd7f1 100644 --- a/Ghidra/Features/Python/src/test.slow/java/ghidra/python/PythonCodeCompletionTest.java +++ b/Ghidra/Features/Python/src/test.slow/java/ghidra/python/PythonCodeCompletionTest.java @@ -111,8 +111,26 @@ public class PythonCodeCompletionTest extends AbstractGhidraHeadedIntegrationTes assertCharsToRemoveEqualsTo("employee.getId(", 0); } + @Test + public void testCompletionsFromArbitraryCaretPositions() { + // '' designates the position of the caret + + String testCase; + testCase = "MY_TUP"; + assertCompletionsInclude(testCase, List.of("my_tuple")); + assertCharsToRemoveEqualsTo(testCase, "my_tup".length()); + + testCase = "employee.Get; (4, 2+2)"; + assertCompletionsInclude(testCase, List.of("getId", "getName")); + assertCharsToRemoveEqualsTo(testCase, "Get".length()); + + testCase = "bar = e if 2+2==4 else 'baz'"; + assertCompletionsInclude(testCase, List.of("error_function", "employee")); + assertCharsToRemoveEqualsTo(testCase, "e".length()); + } + private void assertCompletionsInclude(String command, Collection expectedCompletions) { - Set completions = interpreter.getCommandCompletions(command, false) + Set completions = getCodeCompletions(command) .stream() .map(c -> c.getInsertion()) .collect(Collectors.toSet()); @@ -125,12 +143,23 @@ public class PythonCodeCompletionTest extends AbstractGhidraHeadedIntegrationTes } private void assertCharsToRemoveEqualsTo(String command, int expectedCharsToRemove) { - for (CodeCompletion comp : interpreter.getCommandCompletions(command, false)) { + for (CodeCompletion comp : getCodeCompletions(command)) { assertEquals(String.format("%s; field 'charsToRemove' ", comp), expectedCharsToRemove, comp.getCharsToRemove()); } } + private List getCodeCompletions(String command) { + // extract the caret position and generate completions relative to that place + int caretPos = command.indexOf(""); + command.replace("", ""); + if (caretPos == -1) { + caretPos = command.length(); + } + + return interpreter.getCommandCompletions(command, false, caretPos); + } + private void executePythonProgram(String code) { try { File tempFile = tempScriptFolder.newFile();