diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListener.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListener.java index dcbf7358..cad325c4 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListener.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListener.java @@ -81,7 +81,10 @@ private boolean isCustomizedEditorType(final IWorkbenchPart part) { } private void attachDocumentListenerAndUpdateActiveDocument(final ITextEditor editor) { - var document = editor.getDocumentProvider().getDocument(editor.getEditorInput()); + var document = resolveDocument(editor); + if (document == null) { + return; + } attachDocumentListenerAndUpdateActiveDocument(document); } @@ -90,6 +93,29 @@ private void attachDocumentListenerAndUpdateActiveDocument(final IDocument docum setActiveDocument(document); } + /** + * Resolves the document backing the given editor, or {@code null} when it + * cannot be resolved. + * + *

+ * {@link ITextEditor#getDocumentProvider()} is an optional part of the Eclipse + * API and third party editors may return {@code null} from it. Dereferencing it + * unconditionally throws a {@link NullPointerException} out of the + * {@code IPartListener2} callback, which the workbench surfaces as a generic + * error dialog on every editor activation. + *

+ */ + private static IDocument resolveDocument(final ITextEditor editor) { + if (editor == null) { + return null; + } + var documentProvider = editor.getDocumentProvider(); + if (documentProvider == null) { + return null; + } + return documentProvider.getDocument(editor.getEditorInput()); + } + private void detachDocumentListenerFromLastActiveDocument() { if (activeDocument != null) { activeDocument.removeDocumentListener(docListener); @@ -107,7 +133,7 @@ public void onStart() { public void run() { var editor = getActiveTextEditor(); if (editor != null) { - var document = editor.getDocumentProvider().getDocument(editor.getEditorInput()); + var document = resolveDocument(editor); if (document == null) { Display.getDefault().timerExec(1000, this); return; diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/QEclipseEditorUtils.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/QEclipseEditorUtils.java index c59f3cf2..96c9a8a7 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/QEclipseEditorUtils.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/QEclipseEditorUtils.java @@ -76,6 +76,27 @@ private static ISelection getSelection(final ITextEditor textEditor) { return textEditor.getSelectionProvider().getSelection(); } + /** + * Resolves the document backing the given editor, or {@code null} when it + * cannot be resolved. + * + *

+ * {@link ITextEditor#getDocumentProvider()} is an optional part of the Eclipse + * API: third party editors may return {@code null} from it. Callers must + * therefore treat both the provider and the document it returns as nullable. + *

+ */ + private static IDocument getDocument(final ITextEditor editor) { + if (editor == null) { + return null; + } + var documentProvider = editor.getDocumentProvider(); + if (documentProvider == null) { + return null; + } + return documentProvider.getDocument(editor.getEditorInput()); + } + private static ITextEditor asTextEditor(final IEditorPart editorPart) { if (editorPart instanceof ITextEditor) { return (ITextEditor) editorPart; @@ -194,7 +215,10 @@ public static Optional getSelectionRange(final ITextEditor editor) { return Optional.empty(); } ISelection selection = getSelection(editor); - var document = editor.getDocumentProvider().getDocument(editor.getEditorInput()); + var document = getDocument(editor); + if (document == null) { + return Optional.empty(); + } if (selection instanceof ITextSelection textSelection) { try { int startOffset = textSelection.getOffset(); @@ -232,7 +256,10 @@ public static Optional insertAtCursor(final String text) { } var selection = editor.getSelectionProvider().getSelection(); - var document = editor.getDocumentProvider().getDocument(editor.getEditorInput()); + var document = getDocument(editor); + if (document == null) { + return Optional.empty(); + } if (selection instanceof ITextSelection textSelection) { try { int startOffset = textSelection.getOffset(); diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListenerTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListenerTest.java index c4f850a9..8dffb03d 100644 --- a/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListenerTest.java +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/AutoTriggerPartListenerTest.java @@ -3,6 +3,7 @@ package software.aws.toolkits.eclipse.amazonq.util; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.atLeast; @@ -27,6 +28,7 @@ import org.eclipse.ui.IEditorInput; import org.eclipse.ui.IWorkbenchPart; import org.eclipse.ui.IWorkbenchPartReference; +import org.eclipse.ui.texteditor.IDocumentProvider; import org.eclipse.ui.texteditor.ITextEditor; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -151,6 +153,52 @@ void testShutdownRoutine() { verify(docMock, times(removeDocListenerInvocNumber + 1)).removeDocumentListener(docListenerMock); } + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + void testPartActivateWithNullDocumentProvider() { + // Test case: an ITextEditor that returns null from getDocumentProvider() must + // be ignored instead of throwing out of the part listener callback + AutoTriggerPartListener partListener = new AutoTriggerPartListener(docListenerMock); + ITextEditor editorWithoutProvider = mock(ITextEditor.class); + when(editorWithoutProvider.getDocumentProvider()).thenReturn(null); + IWorkbenchPartReference partRefMock = mock(IWorkbenchPartReference.class); + when(partRefMock.getPart(any(Boolean.class))).thenReturn(editorWithoutProvider); + + int addDocListenerInvocNumber = countDocInvocations("addDocumentListener"); + int removeDocListenerInvocNumber = countDocInvocations("removeDocumentListener"); + + assertDoesNotThrow(() -> partListener.partActivated(partRefMock)); + verify(docMock, times(addDocListenerInvocNumber)).addDocumentListener(docListenerMock); + + // Test case: no active document was recorded, so deactivation is also a no-op + assertDoesNotThrow(() -> partListener.partDeactivated(partRefMock)); + verify(docMock, times(removeDocListenerInvocNumber)).removeDocumentListener(docListenerMock); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + void testPartActivateWithNullDocument() { + // Test case: a provider that returns a null document must be ignored rather + // than dereferenced when attaching the document listener + AutoTriggerPartListener partListener = new AutoTriggerPartListener(docListenerMock); + IDocumentProvider providerWithoutDocument = mock(IDocumentProvider.class); + when(providerWithoutDocument.getDocument(any())).thenReturn(null); + ITextEditor editorWithoutDocument = mock(ITextEditor.class); + when(editorWithoutDocument.getDocumentProvider()).thenReturn(providerWithoutDocument); + IWorkbenchPartReference partRefMock = mock(IWorkbenchPartReference.class); + when(partRefMock.getPart(any(Boolean.class))).thenReturn(editorWithoutDocument); + + int addDocListenerInvocNumber = countDocInvocations("addDocumentListener"); + + assertDoesNotThrow(() -> partListener.partActivated(partRefMock)); + verify(docMock, times(addDocListenerInvocNumber)).addDocumentListener(docListenerMock); + } + + private static int countDocInvocations(final String methodName) { + return (int) mockingDetails(docMock).getInvocations().stream() + .filter(inv -> inv.getMethod().getName().equals(methodName)).count(); + } + static void mockEditor() { when(editorMock.getDocumentProvider().getDocument(any(IEditorInput.class))).thenReturn(docMock); }