From 0434738cfce2e13240004ca291e186dbd87e5823 Mon Sep 17 00:00:00 2001
From: Lars Vogel
Date: Wed, 8 Jul 2026 11:24:46 +0200
Subject: [PATCH] Add opt-in block end code mining
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Echo a block's opening line at its closing brace, prefixed with " // ", so an
isolated } tells which block it terminates.
Braces are matched structurally, without a parser, so there is no dependency on
JDT or a language model. Braces in comments and string literals are skipped
where the document has a partitioning that marks code as the default content
type; a partitioning that uses content types of its own, as TextMate does for
every text file, cannot say where the code is and is ignored. Only blocks
spanning at least a configurable number of lines are annotated, 20 by default.
Nothing in the platform recomputes minings on a document change, JDT brings its
own reconciler for that, so the provider follows the document itself and
refreshes shortly after typing stops.
The feature is off by default and enabled on the Text Editors preference page,
where the code mining settings now share a group with the existing problem
annotation ones.
Assisted-by: multiple AI agents and layers of automated tooling 🤖
---
.../org.eclipse.ui.editors/plugin.properties | 1 +
bundles/org.eclipse.ui.editors/plugin.xml | 4 +
.../TextEditorDefaultsPreferencePage.java | 51 ++-
.../editors/text/TextEditorMessages.java | 5 +
.../text/TextEditorMessages.properties | 5 +
...ecoratedTextEditorPreferenceConstants.java | 4 +
.../plugin.properties | 1 +
.../plugin.xml | 5 +
...BlockEndCodeMiningPreferenceConstants.java | 31 ++
.../codemining/BlockEndCodeMining.java | 37 ++
.../BlockEndCodeMiningProvider.java | 366 ++++++++++++++++
.../BlockEndCodeMiningRenderingTest.java | 141 ++++++
.../ui/editors/tests/EditorsTestSuite.java | 2 +
.../TextEditorDefaultsPreferencePageTest.java | 133 ++++++
.../tests/BlockEndCodeMiningTest.java | 408 ++++++++++++++++++
.../tests/WorkbenchTextEditorTestSuite.java | 1 +
16 files changed, 1182 insertions(+), 13 deletions(-)
create mode 100644 bundles/org.eclipse.ui.workbench.texteditor/src/org/eclipse/ui/internal/texteditor/BlockEndCodeMiningPreferenceConstants.java
create mode 100644 bundles/org.eclipse.ui.workbench.texteditor/src/org/eclipse/ui/internal/texteditor/codemining/BlockEndCodeMining.java
create mode 100644 bundles/org.eclipse.ui.workbench.texteditor/src/org/eclipse/ui/internal/texteditor/codemining/BlockEndCodeMiningProvider.java
create mode 100644 tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/BlockEndCodeMiningRenderingTest.java
create mode 100644 tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/TextEditorDefaultsPreferencePageTest.java
create mode 100644 tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/BlockEndCodeMiningTest.java
diff --git a/bundles/org.eclipse.ui.editors/plugin.properties b/bundles/org.eclipse.ui.editors/plugin.properties
index f43d57fe4fc..b41a2acee1f 100644
--- a/bundles/org.eclipse.ui.editors/plugin.properties
+++ b/bundles/org.eclipse.ui.editors/plugin.properties
@@ -125,6 +125,7 @@ preferenceKeywords.lineSpacing= line space height spacing
preferenceKeywords.lineNumber= line numbers
preferenceKeywords.printMargin= print margin
preferenceKeywords.annotationCodeMining= annotation code mining marker error warning info
+preferenceKeywords.blockEndCodeMining= block end code mining closing brace curly
preferenceKeywords.annotations= annotations vertical ruler overview colors text editor
preferenceKeywords.quickdiff= quick diff compare reference colors text editor changes
preferenceKeywords.accessibility= accessibility caret cursor quick diff text editor ruler
diff --git a/bundles/org.eclipse.ui.editors/plugin.xml b/bundles/org.eclipse.ui.editors/plugin.xml
index 6d8ae756911..f1030e74d8b 100644
--- a/bundles/org.eclipse.ui.editors/plugin.xml
+++ b/bundles/org.eclipse.ui.editors/plugin.xml
@@ -288,6 +288,7 @@
+
+
diff --git a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorDefaultsPreferencePage.java b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorDefaultsPreferencePage.java
index 29fc26f8684..a6b8c208801 100644
--- a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorDefaultsPreferencePage.java
+++ b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorDefaultsPreferencePage.java
@@ -38,6 +38,7 @@
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Group;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Link;
import org.eclipse.swt.widgets.Shell;
@@ -79,6 +80,8 @@
import org.eclipse.ui.internal.editors.text.TextEditorDefaultsPreferencePage.EnumeratedDomain.EnumValue;
import org.eclipse.ui.internal.editors.text.codemining.annotation.AnnotationCodeMiningPreferenceConstants;
+import org.eclipse.ui.internal.texteditor.BlockEndCodeMiningPreferenceConstants;
+
import org.eclipse.ui.texteditor.AbstractDecoratedTextEditorPreferenceConstants;
import org.eclipse.ui.texteditor.AbstractTextEditor;
@@ -808,6 +811,9 @@ private OverlayPreferenceStore createOverlayStore() {
overlayKeys.add(new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.INT, AnnotationCodeMiningPreferenceConstants.SHOW_ANNOTATION_CODE_MINING_LEVEL));
overlayKeys.add(new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.INT, AnnotationCodeMiningPreferenceConstants.SHOW_ANNOTATION_CODE_MINING_MAX));
+ overlayKeys.add(new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.BOOLEAN, BlockEndCodeMiningPreferenceConstants.SHOW_BLOCK_END_CODE_MINING));
+ overlayKeys.add(new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.INT, BlockEndCodeMiningPreferenceConstants.BLOCK_END_CODE_MINING_MIN_LINES));
+
overlayKeys.add(new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.BOOLEAN, AbstractDecoratedTextEditorPreferenceConstants.EDITOR_STICKY_SCROLLING_ENABLED));
overlayKeys.add(new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.INT, AbstractDecoratedTextEditorPreferenceConstants.EDITOR_STICKY_SCROLLING_MAXIMUM_COUNT));
@@ -995,8 +1001,26 @@ public void widgetSelected(SelectionEvent e) {
Preference smartHomeEnd= new Preference(AbstractDecoratedTextEditorPreferenceConstants.EDITOR_SMART_HOME_END, label, null);
addCheckBox(appearanceComposite, smartHomeEnd, new BooleanDomain(), 0);
+ label= TextEditorMessages.TextEditorDefaultsPreferencePage_stickyScrollingEnabled;
+ Preference stickyScrollingEnabled= new Preference(AbstractDecoratedTextEditorPreferenceConstants.EDITOR_STICKY_SCROLLING_ENABLED, label, null);
+ Button stickyScrollingEnabledButton= addCheckBox(appearanceComposite, stickyScrollingEnabled, new BooleanDomain(), 0);
+
+ label= TextEditorMessages.TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount;
+ String description= TextEditorMessages.TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount;
+ Preference stickyScrollingMaximumCount= new Preference(AbstractDecoratedTextEditorPreferenceConstants.EDITOR_STICKY_SCROLLING_MAXIMUM_COUNT, label, description);
+ final IntegerDomain stickyScrollingMaximumCountDomain= new IntegerDomain(1, 10);
+ final Control[] stickyScrollingMaximumCountControls= addTextField(appearanceComposite, stickyScrollingMaximumCount, stickyScrollingMaximumCountDomain, 15, 20);
+ createDependency(stickyScrollingEnabledButton, stickyScrollingEnabled, stickyScrollingMaximumCountControls);
+
+ Group codeMiningGroup= new Group(appearanceComposite, SWT.NONE);
+ codeMiningGroup.setText(TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_group);
+ codeMiningGroup.setLayout(new GridLayout(2, false));
+ gd= new GridData(GridData.FILL_HORIZONTAL);
+ gd.horizontalSpan= 2;
+ codeMiningGroup.setLayoutData(gd);
+
label= TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_show;
- String description= TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_description;
+ description= TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_description;
Preference showCodeMinings= new Preference(AnnotationCodeMiningPreferenceConstants.SHOW_ANNOTATION_CODE_MINING_LEVEL, label, description);
EnumeratedDomain codeMiningsDomain= new EnumeratedDomain();
codeMiningsDomain.addValue(new EnumValue(AnnotationCodeMiningPreferenceConstants.SHOW_ANNOTATION_CODE_MINING_LEVEL__NONE, TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_none));
@@ -1005,13 +1029,13 @@ public void widgetSelected(SelectionEvent e) {
TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_ErrorWarnings));
codeMiningsDomain.addValue(new EnumValue(AnnotationCodeMiningPreferenceConstants.SHOW_ANNOTATION_CODE_MINING_LEVEL__ERROR_WARNING_INFO,
TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_ErrorWarningsInfo));
- final Control[] showCodeMiningsControls= addCombo(appearanceComposite, showCodeMinings, codeMiningsDomain, 0);
+ final Control[] showCodeMiningsControls= addCombo(codeMiningGroup, showCodeMinings, codeMiningsDomain, 0);
label= TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_max;
description= TextEditorMessages.TextEditorDefaultsPreferencePage_codeMinings_max_description;
Preference maxCodeMinings= new Preference(AnnotationCodeMiningPreferenceConstants.SHOW_ANNOTATION_CODE_MINING_MAX, label, description);
IntegerDomain maxCodeMiningsDomain= new IntegerDomain(0, 99999);
- Control[] maxCodeMiningsControls= addTextField(appearanceComposite, maxCodeMinings, maxCodeMiningsDomain, 15, 20);
+ Control[] maxCodeMiningsControls= addTextField(codeMiningGroup, maxCodeMinings, maxCodeMiningsDomain, 15, 20);
final SelectionListener codeMiningsListener= new SelectionAdapter() {
@Override
@@ -1027,16 +1051,17 @@ public void widgetSelected(SelectionEvent e) {
((Combo) showCodeMiningsControls[1]).addSelectionListener(codeMiningsListener);
fMasterSlaveListeners.add(codeMiningsListener);
- label= TextEditorMessages.TextEditorDefaultsPreferencePage_stickyScrollingEnabled;
- Preference stickyScrollingEnabled= new Preference(AbstractDecoratedTextEditorPreferenceConstants.EDITOR_STICKY_SCROLLING_ENABLED, label, null);
- Button stickyScrollingEnabledButton= addCheckBox(appearanceComposite, stickyScrollingEnabled, new BooleanDomain(), 0);
-
- label= TextEditorMessages.TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount;
- description= TextEditorMessages.TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount;
- Preference stickyScrollingMaximumCount= new Preference(AbstractDecoratedTextEditorPreferenceConstants.EDITOR_STICKY_SCROLLING_MAXIMUM_COUNT, label, description);
- final IntegerDomain stickyScrollingMaximumCountDomain= new IntegerDomain(1, 10);
- final Control[] stickyScrollingMaximumCountControls= addTextField(appearanceComposite, stickyScrollingMaximumCount, stickyScrollingMaximumCountDomain, 15, 20);
- createDependency(stickyScrollingEnabledButton, stickyScrollingEnabled, stickyScrollingMaximumCountControls);
+ label= TextEditorMessages.TextEditorDefaultsPreferencePage_blockEndCodeMining;
+ description= TextEditorMessages.TextEditorDefaultsPreferencePage_blockEndCodeMining_description;
+ Preference blockEndCodeMining= new Preference(BlockEndCodeMiningPreferenceConstants.SHOW_BLOCK_END_CODE_MINING, label, description);
+ Button blockEndCodeMiningButton= addCheckBox(codeMiningGroup, blockEndCodeMining, new BooleanDomain(), 0);
+
+ label= TextEditorMessages.TextEditorDefaultsPreferencePage_blockEndCodeMining_minLines;
+ description= TextEditorMessages.TextEditorDefaultsPreferencePage_blockEndCodeMining_minLines_description;
+ Preference blockEndCodeMiningMinLines= new Preference(BlockEndCodeMiningPreferenceConstants.BLOCK_END_CODE_MINING_MIN_LINES, label, description);
+ IntegerDomain blockEndCodeMiningMinLinesDomain= new IntegerDomain(2, 99999);
+ Control[] blockEndCodeMiningMinLinesControls= addTextField(codeMiningGroup, blockEndCodeMiningMinLines, blockEndCodeMiningMinLinesDomain, 15, 20);
+ createDependency(blockEndCodeMiningButton, blockEndCodeMining, blockEndCodeMiningMinLinesControls);
addFiller(appearanceComposite, 2);
diff --git a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.java b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.java
index fdcd1091798..6d2b0d0cfb0 100644
--- a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.java
+++ b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.java
@@ -148,6 +148,11 @@ private TextEditorMessages() {
public static String TextEditorDefaultsPreferencePage_codeMinings_max_description;
public static String TextEditorDefaultsPreferencePage_codeMinings_none;
public static String TextEditorDefaultsPreferencePage_codeMinings_show;
+ public static String TextEditorDefaultsPreferencePage_codeMinings_group;
+ public static String TextEditorDefaultsPreferencePage_blockEndCodeMining;
+ public static String TextEditorDefaultsPreferencePage_blockEndCodeMining_description;
+ public static String TextEditorDefaultsPreferencePage_blockEndCodeMining_minLines;
+ public static String TextEditorDefaultsPreferencePage_blockEndCodeMining_minLines_description;
public static String TextEditorDefaultsPreferencePage_configureWhitespaceCharacterPainterProperties;
public static String TextEditorDefaultsPreferencePage_deleteSpacesAsTabs;
public static String TextEditorDefaultsPreferencePage_enclosed;
diff --git a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.properties b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.properties
index 86575f8f274..68e6a429dc6 100644
--- a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.properties
+++ b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorMessages.properties
@@ -50,6 +50,11 @@ TextEditorDefaultsPreferencePage_codeMinings_max=Maximum annotations shown:
TextEditorDefaultsPreferencePage_codeMinings_max_description=Limits the number of shown annotations to prevent performance issues
TextEditorDefaultsPreferencePage_codeMinings_none=None
TextEditorDefaultsPreferencePage_codeMinings_show=Show code minings &for problem annotations:
+TextEditorDefaultsPreferencePage_codeMinings_group=Code Minings
+TextEditorDefaultsPreferencePage_blockEndCodeMining=Show &block end code minings
+TextEditorDefaultsPreferencePage_blockEndCodeMining_description=Shows the opening line of a code block at its closing brace
+TextEditorDefaultsPreferencePage_blockEndCodeMining_minLines=Minimum block size (lines):
+TextEditorDefaultsPreferencePage_blockEndCodeMining_minLines_description=A block end code mining is only shown when the block spans at least this many lines
TextEditorDefaultsPreferencePage_configureWhitespaceCharacterPainterProperties=Configure visibility of whitespace characters in different regions of a line of text:
TextEditorDefaultsPreferencePage_deleteSpacesAsTabs=Remove &multiple spaces on backspace/delete
TextEditorDefaultsPreferencePage_enclosed=Enclosed
diff --git a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/texteditor/AbstractDecoratedTextEditorPreferenceConstants.java b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/texteditor/AbstractDecoratedTextEditorPreferenceConstants.java
index 2f50bf9e00e..9fa198ae07a 100644
--- a/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/texteditor/AbstractDecoratedTextEditorPreferenceConstants.java
+++ b/bundles/org.eclipse.ui.editors/src/org/eclipse/ui/texteditor/AbstractDecoratedTextEditorPreferenceConstants.java
@@ -28,6 +28,7 @@
import org.eclipse.ui.internal.editors.text.EditorsPlugin;
import org.eclipse.ui.internal.editors.text.EditorsPluginPreferenceInitializer;
+import org.eclipse.ui.internal.texteditor.BlockEndCodeMiningPreferenceConstants;
import org.eclipse.ui.texteditor.spelling.SpellingService;
@@ -883,6 +884,9 @@ public static void initializeDefaultValues(IPreferenceStore store) {
store.setDefault(EDITOR_STICKY_SCROLLING_ENABLED, false);
store.setDefault(EDITOR_STICKY_SCROLLING_MAXIMUM_COUNT, 4);
+ store.setDefault(BlockEndCodeMiningPreferenceConstants.SHOW_BLOCK_END_CODE_MINING, false);
+ store.setDefault(BlockEndCodeMiningPreferenceConstants.BLOCK_END_CODE_MINING_MIN_LINES, BlockEndCodeMiningPreferenceConstants.DEFAULT_MIN_LINES);
+
MarkerAnnotationPreferences.initializeDefaultValues(store);
EditorsPluginPreferenceInitializer.setThemeBasedPreferences(store, false);
diff --git a/bundles/org.eclipse.ui.workbench.texteditor/plugin.properties b/bundles/org.eclipse.ui.workbench.texteditor/plugin.properties
index 0d814966463..a2bac2d52b1 100644
--- a/bundles/org.eclipse.ui.workbench.texteditor/plugin.properties
+++ b/bundles/org.eclipse.ui.workbench.texteditor/plugin.properties
@@ -232,3 +232,4 @@ blockSelectionModeFont.description= The block selection mode font is used by tex
MinimapView.name=Minimap
CodeMining.show.ZWSP=Show ZWSP (Zero-Width Space)
+CodeMining.blockEnd=Show block end code minings
diff --git a/bundles/org.eclipse.ui.workbench.texteditor/plugin.xml b/bundles/org.eclipse.ui.workbench.texteditor/plugin.xml
index 47d2f1a1a34..f910e647a7e 100644
--- a/bundles/org.eclipse.ui.workbench.texteditor/plugin.xml
+++ b/bundles/org.eclipse.ui.workbench.texteditor/plugin.xml
@@ -1554,6 +1554,11 @@
id="org.eclipse.test.codemining.zswpProvider"
label="%CodeMining.show.ZWSP">
+
+
+ * Braces are matched structurally, without a parser: braces in comments and string literals are
+ * skipped where the document has a partitioner, and a document without one is treated as code in
+ * full, so a brace in a character literal can pair up wrongly there.
+ *
+ */
+public class BlockEndCodeMiningProvider extends AbstractCodeMiningProvider implements IPropertyChangeListener {
+
+ /**
+ * A block whose closing brace should be annotated.
+ *
+ * @param endLine the zero-based line of the closing brace
+ * @param label the text to render there
+ */
+ public record BlockEnd(int endLine, String label) {
+ }
+
+ private static final int MAX_LABEL_LENGTH= 100;
+
+ /** Reads like the closing brace comment people write by hand, the mining is drawn right after the brace. */
+ private static final String LABEL_PREFIX= " // "; //$NON-NLS-1$
+
+ private static final Pattern WHITESPACE= Pattern.compile("\\s+"); //$NON-NLS-1$
+
+ /** How many characters to scan between two cancellation checks. */
+ private static final int CANCELLATION_CHECK_INTERVAL= 8192;
+
+ /** How long to wait after a document change before recomputing, in milliseconds. */
+ private static final int UPDATE_DELAY= 500;
+
+ private volatile IPreferenceStore store;
+
+ private volatile boolean enabled;
+
+ private volatile int minLines= BlockEndCodeMiningPreferenceConstants.DEFAULT_MIN_LINES;
+
+ private final Runnable updater= this::updateCodeMinings;
+
+ private final IDocumentListener documentListener= new IDocumentListener() {
+ @Override
+ public void documentAboutToBeChanged(DocumentEvent event) {
+ // Nothing to do.
+ }
+
+ @Override
+ public void documentChanged(DocumentEvent event) {
+ scheduleUpdate();
+ }
+ };
+
+ private IDocument trackedDocument;
+
+ private volatile boolean disposed;
+
+ @Override
+ public CompletableFuture> provideCodeMinings(ITextViewer viewer, IProgressMonitor monitor) {
+ loadStore();
+ IDocument document= viewer.getDocument();
+ // Nothing in the platform recomputes minings on a document change, JDT brings its own
+ // reconciler for that, so follow the document here.
+ trackDocument(enabled ? document : null);
+ if (!enabled || document == null) {
+ return CompletableFuture.completedFuture(Collections.emptyList());
+ }
+ int blockSize= minLines;
+ // Scanning a large document takes long enough to be felt, and this is called on the UI
+ // thread; the mining infrastructure waits for the future either way.
+ return CompletableFuture.supplyAsync(() -> collectMinings(document, blockSize, monitor));
+ }
+
+ private List collectMinings(IDocument document, int blockSize, IProgressMonitor monitor) {
+ List minings= new ArrayList<>();
+ for (BlockEnd blockEnd : computeBlockEnds(document, blockSize, monitor)) {
+ if (monitor != null && monitor.isCanceled()) {
+ break;
+ }
+ try {
+ minings.add(new BlockEndCodeMining(document, blockEnd.endLine(), blockEnd.label(), this));
+ } catch (BadLocationException e) {
+ // Skip minings that can no longer be positioned.
+ }
+ }
+ return minings;
+ }
+
+ /**
+ * Computes the blocks that should be annotated in the given document.
+ *
+ * @param document the document to scan, may be null
+ * @param minLines the minimum number of lines a block must span
+ * @param monitor the monitor to check for cancellation, may be null
+ * @return the closing braces to annotate, innermost block first
+ */
+ public static List computeBlockEnds(IDocument document, int minLines, IProgressMonitor monitor) {
+ List result= new ArrayList<>();
+ if (document == null || document.getLength() == 0) {
+ return result;
+ }
+ Deque openBraces= new ArrayDeque<>();
+ int sinceLastCheck= 0;
+ try {
+ for (ITypedRegion region : computeCodeRegions(document)) {
+ if (monitor != null && monitor.isCanceled()) {
+ return result;
+ }
+ if (!IDocument.DEFAULT_CONTENT_TYPE.equals(region.getType())) {
+ continue;
+ }
+ int regionOffset= region.getOffset();
+ String code= document.get(regionOffset, region.getLength());
+ for (int i= 0; i < code.length(); i++) {
+ if (++sinceLastCheck >= CANCELLATION_CHECK_INTERVAL) {
+ sinceLastCheck= 0;
+ if (monitor != null && monitor.isCanceled()) {
+ return result;
+ }
+ }
+ char c= code.charAt(i);
+ if (c == '{') {
+ openBraces.push(regionOffset + i);
+ } else if (c == '}' && !openBraces.isEmpty()) {
+ collectIfSignificant(document, openBraces.pop(), regionOffset + i, minLines, result);
+ }
+ }
+ }
+ } catch (BadLocationException e) {
+ // Return what has been collected so far.
+ }
+ return result;
+ }
+
+ /**
+ * Returns the partitions to scan for braces, so that comments and string literals can be left
+ * out. Only a partitioning that marks code as {@link IDocument#DEFAULT_CONTENT_TYPE} can say
+ * where the code is; TextMate for instance partitions every text file into content types of its
+ * own, and reading those as "no code here" would leave the document unannotated. A document
+ * with no such partitioning is treated as code in full.
+ */
+ private static ITypedRegion[] computeCodeRegions(IDocument document) throws BadLocationException {
+ ITypedRegion[] wholeDocument= { new TypedRegion(0, document.getLength(), IDocument.DEFAULT_CONTENT_TYPE) };
+ if (!(document instanceof IDocumentExtension3 extension)) {
+ return wholeDocument;
+ }
+ ITypedRegion[] partitions= computePartitioning(extension, document, IDocumentExtension3.DEFAULT_PARTITIONING);
+ if (partitions == null) {
+ // Editors like JDT register their partitioner under their own partitioning name only.
+ // Sorting keeps the choice reproducible, getPartitionings() is unordered.
+ String[] partitionings= extension.getPartitionings().clone();
+ Arrays.sort(partitionings);
+ for (String partitioning : partitionings) {
+ if (IDocumentExtension3.DEFAULT_PARTITIONING.equals(partitioning)) {
+ continue;
+ }
+ partitions= computePartitioning(extension, document, partitioning);
+ if (partitions != null) {
+ break;
+ }
+ }
+ }
+ return partitions != null ? partitions : wholeDocument;
+ }
+
+ /**
+ * Returns the partitioning of the document, or null when the given partitioning
+ * has no partitioner or does not mark any region as code.
+ */
+ private static ITypedRegion[] computePartitioning(IDocumentExtension3 extension, IDocument document,
+ String partitioning) throws BadLocationException {
+ if (extension.getDocumentPartitioner(partitioning) == null) {
+ return null;
+ }
+ ITypedRegion[] partitions;
+ try {
+ partitions= extension.computePartitioning(partitioning, 0, document.getLength(), false);
+ } catch (BadPartitioningException e) {
+ return null;
+ }
+ for (ITypedRegion partition : partitions) {
+ if (IDocument.DEFAULT_CONTENT_TYPE.equals(partition.getType())) {
+ return partitions;
+ }
+ }
+ return null;
+ }
+
+ private static void collectIfSignificant(IDocument document, int openOffset, int closeOffset, int minLines,
+ List result) throws BadLocationException {
+ int openLine= document.getLineOfOffset(openOffset);
+ int closeLine= document.getLineOfOffset(closeOffset);
+ int lineCount= closeLine - openLine + 1;
+ // A single line block never needs a marker, both braces are visible at once.
+ if (lineCount < Math.max(2, minLines)) {
+ return;
+ }
+ String label= computeLabel(document, openOffset, openLine);
+ if (!label.isEmpty()) {
+ result.add(new BlockEnd(closeLine, LABEL_PREFIX + label));
+ }
+ }
+
+ private static String computeLabel(IDocument document, int openBraceOffset, int openLine) throws BadLocationException {
+ int lineOffset= document.getLineOffset(openLine);
+ String label= normalize(document.get(lineOffset, openBraceOffset - lineOffset));
+ if (!label.isEmpty()) {
+ return label;
+ }
+ // The opening brace is the first token on its line (e.g. Allman style): use
+ // the closest preceding non-blank line instead.
+ for (int line= openLine - 1; line >= 0; line--) {
+ IRegion region= document.getLineInformation(line);
+ String text= normalize(document.get(region.getOffset(), region.getLength()));
+ if (!text.isEmpty()) {
+ return text;
+ }
+ }
+ return ""; //$NON-NLS-1$
+ }
+
+ private static String normalize(String text) {
+ String collapsed= WHITESPACE.matcher(text).replaceAll(" ").trim(); //$NON-NLS-1$
+ while (collapsed.endsWith("{")) { //$NON-NLS-1$
+ collapsed= collapsed.substring(0, collapsed.length() - 1).trim();
+ }
+ if (collapsed.length() > MAX_LABEL_LENGTH) {
+ collapsed= collapsed.substring(0, MAX_LABEL_LENGTH) + "\u2026"; //$NON-NLS-1$
+ }
+ return collapsed;
+ }
+
+ @Override
+ public void propertyChange(PropertyChangeEvent event) {
+ String property= event.getProperty();
+ if (BlockEndCodeMiningPreferenceConstants.SHOW_BLOCK_END_CODE_MINING.equals(property)
+ || BlockEndCodeMiningPreferenceConstants.BLOCK_END_CODE_MINING_MIN_LINES.equals(property)) {
+ readPreferences(store);
+ updateCodeMinings();
+ }
+ }
+
+ private void updateCodeMinings() {
+ ITextViewer viewer= getAdapter(ITextViewer.class);
+ if (viewer instanceof ISourceViewerExtension5 codeMiningExtension) {
+ codeMiningExtension.updateCodeMinings();
+ }
+ }
+
+ private synchronized void trackDocument(IDocument document) {
+ if (trackedDocument == document) {
+ return;
+ }
+ if (trackedDocument != null) {
+ trackedDocument.removeDocumentListener(documentListener);
+ }
+ trackedDocument= document;
+ if (document != null) {
+ document.addDocumentListener(documentListener);
+ }
+ }
+
+ /** Recomputes once the typing has paused, timerExec collapses the pending updates into one. */
+ private void scheduleUpdate() {
+ Display display= displayOfViewer();
+ if (display != null) {
+ display.asyncExec(() -> {
+ if (!disposed) {
+ display.timerExec(UPDATE_DELAY, updater);
+ }
+ });
+ }
+ }
+
+ private Display displayOfViewer() {
+ ITextViewer viewer= getAdapter(ITextViewer.class);
+ StyledText widget= viewer != null ? viewer.getTextWidget() : null;
+ return widget != null && !widget.isDisposed() ? widget.getDisplay() : null;
+ }
+
+ @Override
+ public synchronized void dispose() {
+ disposed= true;
+ Display display= displayOfViewer();
+ if (display != null) {
+ display.timerExec(-1, updater);
+ }
+ trackDocument(null);
+ if (store != null) {
+ store.removePropertyChangeListener(this);
+ store= null;
+ }
+ super.dispose();
+ }
+
+ /**
+ * Reads the preferences and starts listening for their changes, once. Code minings are computed
+ * on a background thread, so this can be reached concurrently.
+ */
+ private synchronized void loadStore() {
+ if (store != null) {
+ return;
+ }
+ IPreferenceStore preferenceStore= getAdapter(IPreferenceStore.class);
+ readPreferences(preferenceStore);
+ if (preferenceStore != null) {
+ preferenceStore.addPropertyChangeListener(this);
+ store= preferenceStore;
+ }
+ }
+
+ private void readPreferences(IPreferenceStore preferenceStore) {
+ if (preferenceStore == null) {
+ enabled= false;
+ minLines= BlockEndCodeMiningPreferenceConstants.DEFAULT_MIN_LINES;
+ return;
+ }
+ enabled= preferenceStore.getBoolean(BlockEndCodeMiningPreferenceConstants.SHOW_BLOCK_END_CODE_MINING);
+ minLines= preferenceStore.getInt(BlockEndCodeMiningPreferenceConstants.BLOCK_END_CODE_MINING_MIN_LINES);
+ }
+}
diff --git a/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/BlockEndCodeMiningRenderingTest.java b/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/BlockEndCodeMiningRenderingTest.java
new file mode 100644
index 00000000000..e5401b4eeb4
--- /dev/null
+++ b/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/BlockEndCodeMiningRenderingTest.java
@@ -0,0 +1,141 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Eclipse Platform contributors.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.ui.editors.tests;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.eclipse.swt.custom.StyleRange;
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.widgets.Control;
+
+import org.eclipse.core.filesystem.EFS;
+
+import org.eclipse.jface.preference.IPreferenceStore;
+
+import org.eclipse.jface.text.BadLocationException;
+import org.eclipse.jface.text.IDocument;
+
+import org.eclipse.ui.PlatformUI;
+import org.eclipse.ui.ide.IDE;
+import org.eclipse.ui.internal.editors.text.EditorsPlugin;
+import org.eclipse.ui.tests.harness.util.DisplayHelper;
+
+import org.eclipse.ui.texteditor.AbstractTextEditor;
+
+/**
+ * Verifies that the block end code mining is rendered in a running text editor.
+ */
+public class BlockEndCodeMiningRenderingTest {
+
+ /** Persisted keys of the block end code mining preferences, they must stay stable. */
+ private static final String SHOW_BLOCK_END_CODE_MINING= "showBlockEndCodeMining"; //$NON-NLS-1$
+
+ private static final String BLOCK_END_CODE_MINING_MIN_LINES= "blockEndCodeMiningMinLines"; //$NON-NLS-1$
+
+ /** Zero-based line of the closing brace in the test document. */
+ private static final int CLOSING_BRACE_LINE= 5;
+
+ private static final String SOURCE= "void method() {\n\ta();\n\tb();\n\tc();\n\td();\n}\n"; //$NON-NLS-1$
+
+ private IPreferenceStore store;
+
+ private File file;
+
+ private AbstractTextEditor editor;
+
+ private StyledText widget;
+
+ private IDocument document;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ store= EditorsPlugin.getDefault().getPreferenceStore();
+ store.setValue(SHOW_BLOCK_END_CODE_MINING, true);
+ store.setValue(BLOCK_END_CODE_MINING_MIN_LINES, 5);
+
+ file= File.createTempFile(BlockEndCodeMiningRenderingTest.class.getName(), ".txt"); //$NON-NLS-1$
+ Files.write(file.toPath(), SOURCE.getBytes(StandardCharsets.UTF_8));
+ editor= (AbstractTextEditor) IDE.openEditorOnFileStore(
+ PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(), EFS.getStore(file.toURI()));
+ document= editor.getDocumentProvider().getDocument(editor.getEditorInput());
+ widget= (StyledText) editor.getAdapter(Control.class);
+ }
+
+ @AfterEach
+ void tearDown() {
+ store.setToDefault(SHOW_BLOCK_END_CODE_MINING);
+ store.setToDefault(BLOCK_END_CODE_MINING_MIN_LINES);
+ editor.close(false);
+ file.delete();
+ TestUtil.cleanUp();
+ }
+
+ @Test
+ void miningIsRenderedAtTheClosingBrace() {
+ waitForMining();
+ }
+
+ @Test
+ void miningEchoesTheOpeningLine() throws Exception {
+ int width= waitForMining();
+
+ // A longer opening line has to make the echoed text wider.
+ document.replace(0, "void method".length(), "void methodWithAConsiderablyLongerName"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ assertTrue(DisplayHelper.waitForCondition(widget.getDisplay(), 10_000, () -> reservedWidth(CLOSING_BRACE_LINE) > width),
+ "the echoed text did not follow the opening line"); //$NON-NLS-1$
+ }
+
+ @Test
+ void disablingThePreferenceRemovesTheMining() {
+ waitForMining();
+
+ store.setValue(SHOW_BLOCK_END_CODE_MINING, false);
+
+ assertTrue(DisplayHelper.waitForCondition(widget.getDisplay(), 10_000, () -> reservedWidth(CLOSING_BRACE_LINE) == 0),
+ "code mining still rendered after disabling the preference"); //$NON-NLS-1$
+ }
+
+ /** Waits for the mining to appear at the closing brace and returns the space it reserves. */
+ private int waitForMining() {
+ assertTrue(DisplayHelper.waitForCondition(widget.getDisplay(), 10_000, () -> reservedWidth(CLOSING_BRACE_LINE) > 0),
+ "no code mining rendered at the closing brace"); //$NON-NLS-1$
+ return reservedWidth(CLOSING_BRACE_LINE);
+ }
+
+ /**
+ * Returns the horizontal space the given line reserves through glyph metrics,
+ * which is how a line content code mining is drawn.
+ */
+ private int reservedWidth(int line) {
+ try {
+ int offset= document.getLineOffset(line);
+ int end= Math.min(offset + document.getLineLength(line), widget.getCharCount() - 1);
+ for (int i= offset; i <= end; i++) {
+ StyleRange range= widget.getStyleRangeAtOffset(i);
+ if (range != null && range.metrics != null && range.metrics.width > 0) {
+ return range.metrics.width;
+ }
+ }
+ return 0;
+ } catch (BadLocationException e) {
+ return 0;
+ }
+ }
+}
diff --git a/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/EditorsTestSuite.java b/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/EditorsTestSuite.java
index e8353b3e803..809a63f43d0 100644
--- a/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/EditorsTestSuite.java
+++ b/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/EditorsTestSuite.java
@@ -49,6 +49,8 @@
LargeFileTest.class, CaseActionTest.class,
TextMultiCaretNavigationTest.class,
TextMultiCaretSelectionCommandsTest.class,
+ TextEditorDefaultsPreferencePageTest.class,
+ BlockEndCodeMiningRenderingTest.class,
StickyScrollingControlTest.class,
StickyScrollingHandlerTest.class,
diff --git a/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/TextEditorDefaultsPreferencePageTest.java b/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/TextEditorDefaultsPreferencePageTest.java
new file mode 100644
index 00000000000..aaf02f93224
--- /dev/null
+++ b/tests/org.eclipse.ui.editors.tests/src/org/eclipse/ui/editors/tests/TextEditorDefaultsPreferencePageTest.java
@@ -0,0 +1,133 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Eclipse Platform contributors.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.ui.editors.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+import org.eclipse.jface.preference.IPreferenceStore;
+
+import org.eclipse.ui.PlatformUI;
+import org.eclipse.ui.editors.text.EditorsUI;
+import org.eclipse.ui.internal.editors.text.TextEditorDefaultsPreferencePage;
+
+/**
+ * Tests the code mining settings on the Text Editors preference page.
+ */
+public class TextEditorDefaultsPreferencePageTest {
+
+ /** Persisted keys of the block end code mining preferences, they must stay stable. */
+ private static final String SHOW_BLOCK_END_CODE_MINING= "showBlockEndCodeMining"; //$NON-NLS-1$
+
+ private static final String BLOCK_END_CODE_MINING_MIN_LINES= "blockEndCodeMiningMinLines"; //$NON-NLS-1$
+
+ private Shell shell;
+
+ private TextEditorDefaultsPreferencePage page;
+
+ @BeforeEach
+ public void setUp() {
+ shell= new Shell(PlatformUI.getWorkbench().getDisplay());
+ shell.setLayout(new FillLayout());
+ page= new TextEditorDefaultsPreferencePage();
+ page.init(PlatformUI.getWorkbench());
+ page.createControl(shell);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ page.dispose();
+ shell.dispose();
+ }
+
+ @Test
+ public void blockEndCodeMiningDefaultsAreRegistered() {
+ IPreferenceStore store= EditorsUI.getPreferenceStore();
+
+ assertFalse(store.getDefaultBoolean(SHOW_BLOCK_END_CODE_MINING));
+ assertEquals(20, store.getDefaultInt(BLOCK_END_CODE_MINING_MIN_LINES));
+ }
+
+ @Test
+ public void codeMiningSettingsShareOneGroup() {
+ Group group= codeMiningGroup();
+
+ assertFalse(group.getText().isEmpty(), "the group needs a title"); //$NON-NLS-1$
+ // The annotation level combo with its maximum count field, and the block end
+ // check box with its minimum block size field.
+ assertEquals(1, collect(group, Combo.class).size());
+ assertEquals(1, collect(group, Button.class).size());
+ assertEquals(2, collect(group, Text.class).size());
+ }
+
+ @Test
+ public void minimumBlockSizeFollowsTheBlockEndCheckBox() {
+ Group group= codeMiningGroup();
+ Button blockEndCheckBox= collect(group, Button.class).get(0);
+ Text minimumBlockSize= collect(group, Text.class).get(1);
+
+ assertFalse(blockEndCheckBox.getSelection(), "the feature is opt-in"); //$NON-NLS-1$
+ assertFalse(minimumBlockSize.getEnabled());
+
+ select(blockEndCheckBox, true);
+ assertTrue(minimumBlockSize.getEnabled());
+
+ select(blockEndCheckBox, false);
+ assertFalse(minimumBlockSize.getEnabled());
+ }
+
+ /** Returns the group holding the code mining settings, found by the check box it contains. */
+ private Group codeMiningGroup() {
+ for (Group group : collect(shell, Group.class)) {
+ if (collect(group, Button.class).size() == 1 && collect(group, Combo.class).size() == 1) {
+ return group;
+ }
+ }
+ throw new AssertionError("no code mining group on the page"); //$NON-NLS-1$
+ }
+
+ private static void select(Button button, boolean selected) {
+ button.setSelection(selected);
+ button.notifyListeners(SWT.Selection, new Event());
+ }
+
+ private static List collect(Composite parent, Class type) {
+ List found= new ArrayList<>();
+ for (Control child : parent.getChildren()) {
+ if (type.isInstance(child)) {
+ found.add(type.cast(child));
+ }
+ if (child instanceof Composite composite) {
+ found.addAll(collect(composite, type));
+ }
+ }
+ return found;
+ }
+}
diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/BlockEndCodeMiningTest.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/BlockEndCodeMiningTest.java
new file mode 100644
index 00000000000..ee411307ca5
--- /dev/null
+++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/BlockEndCodeMiningTest.java
@@ -0,0 +1,408 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Eclipse Platform contributors.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.ui.workbench.texteditor.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.eclipse.core.runtime.IProgressMonitor;
+import org.eclipse.core.runtime.NullProgressMonitor;
+
+import org.eclipse.jface.text.Document;
+import org.eclipse.jface.text.DocumentEvent;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.IDocumentPartitioner;
+import org.eclipse.jface.text.ITypedRegion;
+import org.eclipse.jface.text.TypedRegion;
+import org.eclipse.jface.text.rules.EndOfLineRule;
+import org.eclipse.jface.text.rules.FastPartitioner;
+import org.eclipse.jface.text.rules.IPredicateRule;
+import org.eclipse.jface.text.rules.IToken;
+import org.eclipse.jface.text.rules.MultiLineRule;
+import org.eclipse.jface.text.rules.RuleBasedPartitionScanner;
+import org.eclipse.jface.text.rules.SingleLineRule;
+import org.eclipse.jface.text.rules.Token;
+
+import org.eclipse.ui.internal.texteditor.codemining.BlockEndCodeMiningProvider;
+import org.eclipse.ui.internal.texteditor.codemining.BlockEndCodeMiningProvider.BlockEnd;
+
+/**
+ * Tests the structural block matching of {@link BlockEndCodeMiningProvider}.
+ */
+public class BlockEndCodeMiningTest {
+
+ private static final int MIN_LINES= 20;
+
+ private static final String COMMENT= "__comment"; //$NON-NLS-1$
+
+ private static final String STRING= "__string"; //$NON-NLS-1$
+
+ private static IDocument document(String text) {
+ return new Document(text);
+ }
+
+ private static final String TEXTMATE_LIKE= "tm4e.partitioning"; //$NON-NLS-1$
+
+ /**
+ * Returns a document with a partitioner that puts Java-like comments and string
+ * literals into their own partitions.
+ */
+ private static IDocument partitionedDocument(String text) {
+ Document document= new Document(text);
+ IToken comment= new Token(COMMENT);
+ IToken string= new Token(STRING);
+ RuleBasedPartitionScanner scanner= new RuleBasedPartitionScanner();
+ scanner.setPredicateRules(new IPredicateRule[] {
+ new EndOfLineRule("//", comment), //$NON-NLS-1$
+ new MultiLineRule("/*", "*/", comment), //$NON-NLS-1$ //$NON-NLS-2$
+ new SingleLineRule("\"", "\"", string, '\\'), //$NON-NLS-1$ //$NON-NLS-2$
+ });
+ IDocumentPartitioner partitioner= new FastPartitioner(scanner, new String[] { COMMENT, STRING });
+ partitioner.connect(document);
+ document.setDocumentPartitioner(partitioner);
+ return document;
+ }
+
+ /**
+ * Returns a document partitioned the way TextMate partitions every text file: under a
+ * partitioning of its own, and with no region of the default content type.
+ */
+ private static IDocument foreignPartitionedDocument(String text) {
+ Document document= new Document(text);
+ IDocumentPartitioner partitioner= new SingleContentTypePartitioner("tm4e:base"); //$NON-NLS-1$
+ document.setDocumentPartitioner(TEXTMATE_LIKE, partitioner);
+ partitioner.connect(document);
+ return document;
+ }
+
+ /** Reports the whole document as one region of a content type of its own. */
+ private static final class SingleContentTypePartitioner implements IDocumentPartitioner {
+
+ private final String contentType;
+
+ private IDocument document;
+
+ SingleContentTypePartitioner(String contentType) {
+ this.contentType= contentType;
+ }
+
+ @Override
+ public void connect(IDocument newDocument) {
+ document= newDocument;
+ }
+
+ @Override
+ public void disconnect() {
+ document= null;
+ }
+
+ @Override
+ public void documentAboutToBeChanged(DocumentEvent event) {
+ // Nothing to do.
+ }
+
+ @Override
+ public boolean documentChanged(DocumentEvent event) {
+ return false;
+ }
+
+ @Override
+ public String[] getLegalContentTypes() {
+ return new String[] { contentType };
+ }
+
+ @Override
+ public String getContentType(int offset) {
+ return contentType;
+ }
+
+ @Override
+ public ITypedRegion[] computePartitioning(int offset, int length) {
+ return new ITypedRegion[] { new TypedRegion(0, document.getLength(), contentType) };
+ }
+
+ @Override
+ public ITypedRegion getPartition(int offset) {
+ return new TypedRegion(0, document.getLength(), contentType);
+ }
+ }
+
+ private static List blockEnds(IDocument document) {
+ return blockEnds(document, MIN_LINES);
+ }
+
+ private static List blockEnds(IDocument document, int minLines) {
+ return BlockEndCodeMiningProvider.computeBlockEnds(document, minLines, null);
+ }
+
+ private static String body(int lines) {
+ return body(lines, "\n"); //$NON-NLS-1$
+ }
+
+ private static String body(int lines, String delimiter) {
+ StringBuilder builder= new StringBuilder();
+ for (int i= 0; i < lines; i++) {
+ builder.append("\tstatement").append(i).append("();").append(delimiter); //$NON-NLS-1$ //$NON-NLS-2$
+ }
+ return builder.toString();
+ }
+
+ @Test
+ public void longBlockIsAnnotated() {
+ IDocument document= document("void method() {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(26, ends.get(0).endLine());
+ }
+
+ @Test
+ public void shortBlockIsNotAnnotated() {
+ IDocument document= document("void method() {\n" + body(3) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ assertTrue(blockEnds(document).isEmpty());
+ }
+
+ @Test
+ public void blockOfExactlyMinLinesIsAnnotated() {
+ // The opening and the closing brace line count towards the block size.
+ IDocument document= document("void method() {\n" + body(MIN_LINES - 2) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(MIN_LINES - 1, ends.get(0).endLine());
+ }
+
+ @Test
+ public void blockOneLineShorterThanMinLinesIsNotAnnotated() {
+ IDocument document= document("void method() {\n" + body(MIN_LINES - 3) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ assertTrue(blockEnds(document).isEmpty());
+ }
+
+ @Test
+ public void nestingAloneDoesNotQualifyAShortBlock() {
+ IDocument document= document("if (a) {\n\tif (b) {\n\t\tx();\n\t}\n}\n"); //$NON-NLS-1$
+
+ assertTrue(blockEnds(document).isEmpty());
+ }
+
+ @Test
+ public void singleLineBlockIsNotAnnotatedForAnyThreshold() {
+ IDocument document= document("if (a) { x(); }\n"); //$NON-NLS-1$
+
+ assertTrue(blockEnds(document, 1).isEmpty());
+ assertTrue(blockEnds(document, 2).isEmpty());
+ }
+
+ @Test
+ public void nestedBlocksAreReportedInnermostFirst() {
+ IDocument document= document("outer() {\n\tinner() {\n" + body(25) + "\t}\n}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(2, ends.size());
+ assertEquals(" // inner()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(27, ends.get(0).endLine());
+ assertEquals(" // outer()", ends.get(1).label()); //$NON-NLS-1$
+ assertEquals(28, ends.get(1).endLine());
+ }
+
+ @Test
+ public void siblingBlocksAreBothAnnotated() {
+ IDocument document= document("a() {\n" + body(25) + "}\nb() {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+
+ List ends= blockEnds(document);
+
+ assertEquals(2, ends.size());
+ assertEquals(" // a()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(" // b()", ends.get(1).label()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void allmanStyleLabelUsesPrecedingLine() {
+ IDocument document= document("void method()\n{\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void allmanStyleLabelSkipsBlankLines() {
+ IDocument document= document("void method()\n\n \n{\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void blockWithoutAnyPrecedingTextIsNotAnnotated() {
+ IDocument document= document("{\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ assertTrue(blockEnds(document).isEmpty());
+ }
+
+ @Test
+ public void labelCollapsesWhitespaceAndStripsBraces() {
+ IDocument document= document("\tvoid\tmethod(int a) {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method(int a)", ends.get(0).label()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void longLabelIsTruncated() {
+ String name= "m".repeat(200); //$NON-NLS-1$
+ IDocument document= document("void " + name + "() {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ String label= ends.get(0).label();
+ assertEquals(" // ".length() + 100 + 1, label.length()); //$NON-NLS-1$
+ assertTrue(label.endsWith("\u2026"), label); //$NON-NLS-1$
+ }
+
+ @Test
+ public void unbalancedClosingBraceIsIgnored() {
+ IDocument document= document("}\nvoid method() {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void unbalancedOpeningBraceIsIgnored() {
+ IDocument document= document("void broken() {\n" + body(25)); //$NON-NLS-1$
+
+ assertTrue(blockEnds(document).isEmpty());
+ }
+
+ @Test
+ public void carriageReturnLineDelimitersAreSupported() {
+ IDocument document= document("void method() {\r\n" + body(25, "\r\n") + "}\r\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(26, ends.get(0).endLine());
+ }
+
+ @Test
+ public void emptyDocumentYieldsNoMinings() {
+ assertTrue(blockEnds(document("")).isEmpty()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void nullDocumentYieldsNoMinings() {
+ assertTrue(blockEnds(null, MIN_LINES).isEmpty());
+ }
+
+ @Test
+ public void canceledMonitorStopsTheScan() {
+ IDocument document= document("void method() {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+ NullProgressMonitor monitor= new NullProgressMonitor();
+ monitor.setCanceled(true);
+
+ assertTrue(BlockEndCodeMiningProvider.computeBlockEnds(document, MIN_LINES, monitor).isEmpty());
+ }
+
+ @Test
+ public void cancellationIsObservedWithinALargeScan() {
+ // One partition far larger than the cancellation check interval, so the scan
+ // has to notice the cancellation inside the character loop.
+ IDocument document= document("void method() {\n" + body(20000) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+ IProgressMonitor monitor= new NullProgressMonitor() {
+ private int queries;
+
+ @Override
+ public boolean isCanceled() {
+ return ++queries > 1;
+ }
+ };
+
+ assertTrue(BlockEndCodeMiningProvider.computeBlockEnds(document, MIN_LINES, monitor).isEmpty());
+ }
+
+ @Test
+ public void bracesInLineCommentsAreIgnored() {
+ IDocument document= partitionedDocument("void method() {\n\t// } not a block end {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(27, ends.get(0).endLine());
+ }
+
+ @Test
+ public void bracesInBlockCommentsAreIgnored() {
+ IDocument document= partitionedDocument("void method() {\n\t/* }\n\t { */\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(28, ends.get(0).endLine());
+ }
+
+ @Test
+ public void bracesInStringLiteralsAreIgnored() {
+ // The decoy brace is unbalanced, so a scan that does not skip strings would
+ // pair the closing brace with it and label the block from the string line.
+ IDocument document= partitionedDocument("void method() {\n\tString s= \"{\";\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(27, ends.get(0).endLine());
+ }
+
+ @Test
+ public void partitioningWithoutCodeRegionsIsIgnored() {
+ // TextMate installs such a partitioner on every text file. Reading it as "no code
+ // here" would leave every document unannotated.
+ IDocument document= foreignPartitionedDocument("void method() {\n" + body(25) + "}\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ }
+
+ @Test
+ public void partitionedDocumentStillMatchesCodeBraces() {
+ IDocument document= partitionedDocument("void method() { // opening\n" + body(25) + "} // closing\n"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ List ends= blockEnds(document);
+
+ assertEquals(1, ends.size());
+ assertEquals(" // void method()", ends.get(0).label()); //$NON-NLS-1$
+ assertEquals(26, ends.get(0).endLine());
+ }
+}
diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java
index 3ae8d48c338..d15dac56e95 100644
--- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java
+++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java
@@ -52,6 +52,7 @@
FindReplaceOverlayInEditorTest.class,
FindReplaceDialogTest.class,
HistoryStoreTest.class,
+ BlockEndCodeMiningTest.class,
})
public class WorkbenchTextEditorTestSuite {
// see @SelectClasses