diff --git a/bundles/org.eclipse.jface.text/src/org/eclipse/jface/text/source/SourceViewer.java b/bundles/org.eclipse.jface.text/src/org/eclipse/jface/text/source/SourceViewer.java index c8965f3f66f..e63b1b5162c 100644 --- a/bundles/org.eclipse.jface.text/src/org/eclipse/jface/text/source/SourceViewer.java +++ b/bundles/org.eclipse.jface.text/src/org/eclipse/jface/text/source/SourceViewer.java @@ -24,15 +24,19 @@ import java.util.Stack; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.widgets.Canvas; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Layout; import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.core.runtime.Assert; + import org.eclipse.jface.internal.text.NonDeletingPositionUpdater; import org.eclipse.jface.internal.text.StickyHoverManager; import org.eclipse.jface.internal.text.codemining.CodeMiningManager; @@ -46,7 +50,9 @@ import org.eclipse.jface.text.IAutoEditStrategy; import org.eclipse.jface.text.IBlockTextSelection; import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension3; import org.eclipse.jface.text.IDocumentExtension4; +import org.eclipse.jface.text.IDocumentPartitioner; import org.eclipse.jface.text.IPositionUpdater; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.IRewriteTarget; @@ -56,8 +62,11 @@ import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.text.ITextViewerExtension2; import org.eclipse.jface.text.ITextViewerLifecycle; +import org.eclipse.jface.text.ITypedRegion; import org.eclipse.jface.text.Position; import org.eclipse.jface.text.Region; +import org.eclipse.jface.text.TextPresentation; +import org.eclipse.jface.text.TextUtilities; import org.eclipse.jface.text.TextViewer; import org.eclipse.jface.text.codemining.ICodeMiningProvider; import org.eclipse.jface.text.contentassist.IContentAssistant; @@ -71,6 +80,8 @@ import org.eclipse.jface.text.hyperlink.IHyperlinkDetector; import org.eclipse.jface.text.information.IInformationPresenter; import org.eclipse.jface.text.presentation.IPresentationReconciler; +import org.eclipse.jface.text.presentation.IPresentationReconcilerExtension; +import org.eclipse.jface.text.presentation.IPresentationRepairer; import org.eclipse.jface.text.projection.ChildDocument; import org.eclipse.jface.text.quickassist.IQuickAssistAssistant; import org.eclipse.jface.text.quickassist.IQuickAssistInvocationContext; @@ -1393,4 +1404,96 @@ private void uninstallTextViewer() { lifecycles.clear(); } + /** + * Computes syntax-highlighting style ranges for a region of an external document using this + * viewer's configured presentation reconciler and partitioner. + *

+ * This is useful when you want to syntax-color content that is not the document + * currently displayed in the viewer — for example, to preview or render a snippet with the same + * language rules that are active in this viewer. + *

+ *

+ * The viewer's partitioner is temporarily connected to the given document so that its + * presentation repairers can compute the correct highlighting. The viewer's own document is not + * affected. However, if the original document uses a named partitioning (via + * {@link IDocumentExtension3}), its partitioner is briefly disconnected from the original + * document and reconnected to {@code document} for the duration of the call; it is always + * reconnected to the original document before this method returns, even if an exception is + * thrown. + *

+ *

+ * Note: This method must be called from the SWT UI thread. + *

+ * + * @param document the external document whose content should be highlighted; must not be + * {@code null} and must use the same language/content type as this viewer; must + * implement {@link org.eclipse.jface.text.IDocumentExtension3} — if either + * {@code document} or the viewer's current document does not implement + * {@code IDocumentExtension3}, an empty list is returned + * @param region the region within {@code document} for which style ranges are computed; must + * not be {@code null} + * @return the list of {@link org.eclipse.swt.custom.StyleRange}s covering the given region, as + * produced by this viewer's presentation reconciler; never {@code null}, may be empty + * if no repairer is registered for the content type, or if either document does not + * implement {@code IDocumentExtension3} + * @throws BadLocationException if {@code region} is outside the bounds of {@code document} + * @since 3.31 + */ + public List computeStyleRanges(IDocument document, IRegion region) throws BadLocationException { + Assert.isTrue(Display.getCurrent() != null, "computeStyleRanges must be called from SWT UI thread"); //$NON-NLS-1$ + IDocument originalDocument= getDocument(); + Assert.isNotNull(originalDocument, "viewer must have a document before calling computeStyleRanges"); //$NON-NLS-1$ + List result= new ArrayList<>(); + String partitioning= IDocumentExtension3.DEFAULT_PARTITIONING; + IPresentationReconciler reconciler= fPresentationReconciler; + if (reconciler instanceof IPresentationReconcilerExtension ext) { + String extPartitioning= ext.getDocumentPartitioning(); + if (extPartitioning != null && !extPartitioning.isEmpty()) { + partitioning= extPartitioning; + } + } + IDocumentPartitioner originalDocumentPartitioner= null; + IDocumentExtension3 documentExt= null; + if (document instanceof IDocumentExtension3 docExt + && originalDocument instanceof IDocumentExtension3 originalExt) { + documentExt= docExt; + originalDocumentPartitioner= originalExt.getDocumentPartitioner(partitioning); + } else { + return result; + } + IDocumentPartitioner externalDocPartitioner= null; + try { + // Temporarily reconnect the partitioner to the external document so that + // presentation repairers compute highlighting against the right content. + // The finally block always restores both documents to their original state. + externalDocPartitioner= documentExt.getDocumentPartitioner(partitioning); + if (originalDocumentPartitioner != null) { + originalDocumentPartitioner.disconnect(); + originalDocumentPartitioner.connect(document); + documentExt.setDocumentPartitioner(partitioning, originalDocumentPartitioner); + } + TextPresentation presentation= new TextPresentation(region, Math.max(region.getLength() / 10, 16)); + ITypedRegion[] partitioningRegions= TextUtilities.computePartitioning(document, partitioning, region.getOffset(), + region.getLength(), false); + for (ITypedRegion partitioningRegion : partitioningRegions) { + IPresentationRepairer repairer= reconciler.getRepairer(partitioningRegion.getType()); + if (repairer != null) { + repairer.setDocument(document); + repairer.createPresentation(presentation, partitioningRegion); + repairer.setDocument(originalDocument); + } + } + var it= presentation.getAllStyleRangeIterator(); + while (it.hasNext()) { + result.add(it.next()); + } + return result; + } finally { + if (originalDocumentPartitioner != null) { + originalDocumentPartitioner.disconnect(); + originalDocumentPartitioner.connect(originalDocument); + documentExt.setDocumentPartitioner(partitioning, externalDocPartitioner); + } + } + } } diff --git a/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/JFaceTextTestSuite.java b/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/JFaceTextTestSuite.java index eaa2a9bdcec..9d8d5dc6f23 100644 --- a/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/JFaceTextTestSuite.java +++ b/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/JFaceTextTestSuite.java @@ -33,6 +33,7 @@ import org.eclipse.jface.text.tests.rules.WordRuleTest; import org.eclipse.jface.text.tests.source.AnnotationRulerColumnTest; import org.eclipse.jface.text.tests.source.LineNumberRulerColumnTest; +import org.eclipse.jface.text.tests.source.SourceViewerComputeStyleRangesTest; import org.eclipse.jface.text.tests.source.inlined.AnnotationOnTabTest; import org.eclipse.jface.text.tests.source.inlined.LineContentBoundsDrawingTest; import org.eclipse.jface.text.tests.templates.persistence.TemplatePersistenceDataTest; @@ -46,6 +47,7 @@ @SelectClasses({ AnnotationRulerColumnTest.class, LineNumberRulerColumnTest.class, + SourceViewerComputeStyleRangesTest.class, HTML2TextReaderTest.class, TextHoverPopupTest.class, TextPresentationTest.class, diff --git a/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/source/SourceViewerComputeStyleRangesTest.java b/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/source/SourceViewerComputeStyleRangesTest.java new file mode 100644 index 00000000000..be1244d89f4 --- /dev/null +++ b/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/source/SourceViewerComputeStyleRangesTest.java @@ -0,0 +1,338 @@ +/******************************************************************************* + * Copyright (c) 2026 SAP SE and others. + * + * 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.jface.text.tests.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +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.custom.StyleRange; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Shell; + +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension3; +import org.eclipse.jface.text.IDocumentPartitioner; +import org.eclipse.jface.text.Region; +import org.eclipse.jface.text.TextAttribute; +import org.eclipse.jface.text.presentation.PresentationReconciler; +import org.eclipse.jface.text.rules.DefaultDamagerRepairer; +import org.eclipse.jface.text.rules.FastPartitioner; +import org.eclipse.jface.text.rules.IPartitionTokenScanner; +import org.eclipse.jface.text.rules.IRule; +import org.eclipse.jface.text.rules.RuleBasedPartitionScanner; +import org.eclipse.jface.text.rules.RuleBasedScanner; +import org.eclipse.jface.text.rules.SingleLineRule; +import org.eclipse.jface.text.rules.Token; +import org.eclipse.jface.text.source.ISourceViewer; +import org.eclipse.jface.text.source.SourceViewer; +import org.eclipse.jface.text.source.SourceViewerConfiguration; + +/** + * Tests for {@link SourceViewer#computeStyleRanges(IDocument, org.eclipse.jface.text.IRegion)}. + */ +public class SourceViewerComputeStyleRangesTest { + + private static final RGB HIGHLIGHT_RGB= new RGB(0, 0, 255); + private static final String NAMED_PARTITIONING= "test_partitioning"; //$NON-NLS-1$ + + private Shell shell; + private Color highlightColor; + + @BeforeEach + public void setUp() { + shell= new Shell(); + highlightColor= new Color(HIGHLIGHT_RGB); + } + + @AfterEach + public void tearDown() { + highlightColor.dispose(); + shell.dispose(); + } + + @Test + public void testBasicStyleRanges() throws Exception { + SourceViewer viewer= createConfiguredViewer(); + var document= new Document("original content"); + setupDefaultPartitioning(document); + viewer.setDocument(document); + + Document externalDoc= new Document("some 'highlighted' text"); + List styles= viewer.computeStyleRanges(externalDoc, new Region(0, externalDoc.getLength())); + + assertNotNull(styles); + assertFalse(styles.isEmpty(), "Expected style ranges for quoted text"); + // The SingleLineRule for 'x' should produce a style covering 'highlighted' (offsets 5..17) + boolean foundHighlight= styles.stream().anyMatch( + sr -> sr.start == 5 && sr.length == 13 && sr.foreground != null + && HIGHLIGHT_RGB.equals(sr.foreground.getRGB())); + assertTrue(foundHighlight, "Expected a blue highlight style for the quoted region"); + } + + @Test + public void testNoMatchingContent() throws Exception { + SourceViewer viewer= createConfiguredViewer(); + var document= new Document("original content"); + setupDefaultPartitioning(document); + viewer.setDocument(document); + + Document externalDoc= new Document("no special content here"); + List styles= viewer.computeStyleRanges(externalDoc, new Region(0, externalDoc.getLength())); + + assertNotNull(styles); + // All style ranges should have null foreground (default styling) since no rule matches + for (StyleRange sr : styles) { + assertTrue(sr.foreground == null || !HIGHLIGHT_RGB.equals(sr.foreground.getRGB()), + "Expected no highlight color for unmatched content"); + } + } + + @Test + public void testRegionSubset() throws Exception { + SourceViewer viewer= createConfiguredViewer(); + var document= new Document("original content"); + setupDefaultPartitioning(document); + viewer.setDocument(document); + + // Put quoted text at position 10..22 + Document externalDoc= new Document("0123456789'highlighted'rest"); + // Request styles only for the region starting at offset 10, length 13 + List styles= viewer.computeStyleRanges(externalDoc, new Region(10, 13)); + + assertNotNull(styles); + for (StyleRange sr : styles) { + assertTrue(sr.start >= 10, "Style range should start at or after region start"); + assertTrue(sr.start + sr.length <= 23, "Style range should end at or before region end"); + } + boolean foundHighlight= styles.stream().anyMatch( + sr -> sr.foreground != null && HIGHLIGHT_RGB.equals(sr.foreground.getRGB())); + assertTrue(foundHighlight, "Expected highlight within subset region"); + } + + @Test + public void testOriginalDocumentNotAffected() throws Exception { + SourceViewer viewer= createConfiguredViewer(); + String originalContent= "original content"; + Document originalDoc= new Document(originalContent); + IDocumentPartitioner originalPartitioner= setupDefaultPartitioning(originalDoc); + assertNotNull(originalPartitioner); + viewer.setDocument(originalDoc); + + Document externalDoc= new Document("some 'highlighted' text"); + IDocumentPartitioner externalPartitioner= setupDefaultPartitioning(externalDoc); + assertNotNull(externalPartitioner); + viewer.computeStyleRanges(externalDoc, new Region(0, externalDoc.getLength())); + + assertEquals(originalContent, originalDoc.get(), "Original document content must not change"); + assertEquals(originalPartitioner, originalDoc.getDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING), + "Original document partitioner must be restored"); + assertEquals(externalPartitioner, externalDoc.getDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING), + "External document partitioner must be restored"); + } + + @Test + public void testEmptyRegion() throws Exception { + SourceViewer viewer= createConfiguredViewerWithNamedPartitioning(); + var document= new Document("original content"); + setupNamedPartitioning(document); + viewer.setDocument(document); + + Document externalDoc= new Document("some 'highlighted' text"); + setupNamedPartitioning(externalDoc); + List styles= viewer.computeStyleRanges(externalDoc, new Region(0, 0)); + + assertNotNull(styles); + assertEquals(1, styles.size()); + // empty style range + assertEquals(0, styles.get(0).start); + assertEquals(0, styles.get(0).length); + assertNull(styles.get(0).font); + } + + @Test + public void testMultipleStyleRanges() throws Exception { + SourceViewer viewer= createConfiguredViewerWithNamedPartitioning(); + var document= new Document("original content"); + setupNamedPartitioning(document); + viewer.setDocument(document); + + Document externalDoc= new Document("'first' normal 'second' end"); + List styles= viewer.computeStyleRanges(externalDoc, new Region(0, externalDoc.getLength())); + + assertNotNull(styles); + long highlightCount= styles.stream() + .filter(sr -> sr.foreground != null && HIGHLIGHT_RGB.equals(sr.foreground.getRGB())) + .count(); + assertTrue(highlightCount >= 2, "Expected at least 2 highlighted regions, got " + highlightCount); + } + + @Test + public void testBadLocationExceptionForOutOfBoundsRegion() throws Exception { + SourceViewer viewer= createConfiguredViewer(); + var document= new Document("original content"); + setupDefaultPartitioning(document); + viewer.setDocument(document); + + Document externalDoc= new Document("short"); + assertThrows(BadLocationException.class, + () -> viewer.computeStyleRanges(externalDoc, new Region(0, 100))); + } + + @Test + public void testExceptionSafetyPartitionerRestored() throws Exception { + SourceViewer viewer= createConfiguredViewerWithNamedPartitioning(); + Document originalDoc= new Document("original content"); + setupNamedPartitioning(originalDoc); + viewer.setDocument(originalDoc); + IDocumentPartitioner originalPartitioner= originalDoc + .getDocumentPartitioner(NAMED_PARTITIONING); + assertNotNull(originalPartitioner, "Original document should have a named partitioner"); + + Document externalDoc= new Document("short"); + setupNamedPartitioning(externalDoc); + IDocumentPartitioner externalPartitioner= externalDoc + .getDocumentPartitioner(NAMED_PARTITIONING); + assertNotNull(externalPartitioner, "External document should have a named partitioner"); + + try { + viewer.computeStyleRanges(externalDoc, new Region(0, 100)); + } catch (BadLocationException expected) { + // expected + } + + // Verify partitioner was restored to original document + IDocumentPartitioner restoredPartitioner= originalDoc + .getDocumentPartitioner(NAMED_PARTITIONING); + assertNotNull(restoredPartitioner, "Partitioner must be restored to original document after exception"); + assertEquals(originalPartitioner, restoredPartitioner); + + IDocumentPartitioner restoredExternalPartitioner= externalDoc + .getDocumentPartitioner(NAMED_PARTITIONING); + assertNotNull(restoredExternalPartitioner, "External partitioner must be restored to original document after exception"); + assertEquals(externalPartitioner, restoredExternalPartitioner); + } + + @Test + public void testNamedPartitioning() throws Exception { + SourceViewer viewer= createConfiguredViewerWithNamedPartitioning(); + Document originalDoc= new Document("original 'content' here"); + IDocumentPartitioner originalPartitioner= setupNamedPartitioning(originalDoc); + assertNotNull(originalPartitioner); + viewer.setDocument(originalDoc); + + Document externalDoc= new Document("external 'styled' text"); + IDocumentPartitioner externalPartitioner= setupNamedPartitioning(externalDoc); + assertNotNull(externalPartitioner); + List styles= viewer.computeStyleRanges(externalDoc, new Region(0, externalDoc.getLength())); + + assertNotNull(styles); + assertFalse(styles.isEmpty(), "Expected style ranges for quoted text with named partitioning"); + + // Verify partitioner is reconnected to original document + IDocumentPartitioner restoredPartitioner= originalDoc + .getDocumentPartitioner(NAMED_PARTITIONING); + assertNotNull(restoredPartitioner, "Partitioner must be restored to original document"); + assertEquals(originalPartitioner, restoredPartitioner); + + IDocumentPartitioner restoredExternalPartitioner= externalDoc + .getDocumentPartitioner(NAMED_PARTITIONING); + assertNotNull(restoredExternalPartitioner, "External partitioner must be restored to external document"); + assertEquals(externalPartitioner, restoredExternalPartitioner); + } + + @Test + public void testNonDocumentExtension3ReturnsEmpty() throws Exception { + SourceViewer viewer= createConfiguredViewer(); + Document originalDoc= new Document("original content"); + setupDefaultPartitioning(originalDoc); + viewer.setDocument(originalDoc); + + // Create a document that does NOT implement IDocumentExtension3 + // so that computeStyleRanges takes the early-return path + IDocument document= mock(IDocument.class); + List styles= viewer.computeStyleRanges(document, new Region(0, document.getLength())); + + assertNotNull(styles); + assertTrue(styles.isEmpty(), "Expected empty style ranges for non-IDocumentExtension3 document"); + } + + private SourceViewer createConfiguredViewer() { + SourceViewer viewer= new SourceViewer(shell, null, SWT.NONE); + viewer.configure(new SourceViewerConfiguration() { + @Override + public org.eclipse.jface.text.presentation.IPresentationReconciler getPresentationReconciler( + ISourceViewer sourceViewer) { + PresentationReconciler reconciler= new PresentationReconciler(); + DefaultDamagerRepairer dr= new DefaultDamagerRepairer(createScanner()); + reconciler.setDamager(dr, IDocument.DEFAULT_CONTENT_TYPE); + reconciler.setRepairer(dr, IDocument.DEFAULT_CONTENT_TYPE); + return reconciler; + } + }); + return viewer; + } + + private SourceViewer createConfiguredViewerWithNamedPartitioning() { + SourceViewer viewer= new SourceViewer(shell, null, SWT.NONE); + viewer.configure(new SourceViewerConfiguration() { + @Override + public org.eclipse.jface.text.presentation.IPresentationReconciler getPresentationReconciler( + ISourceViewer sourceViewer) { + PresentationReconciler reconciler= new PresentationReconciler(); + reconciler.setDocumentPartitioning(NAMED_PARTITIONING); + DefaultDamagerRepairer dr= new DefaultDamagerRepairer(createScanner()); + reconciler.setDamager(dr, IDocument.DEFAULT_CONTENT_TYPE); + reconciler.setRepairer(dr, IDocument.DEFAULT_CONTENT_TYPE); + return reconciler; + } + }); + return viewer; + } + + private RuleBasedScanner createScanner() { + RuleBasedScanner scanner= new RuleBasedScanner(); + IRule[] rules= new IRule[1]; + rules[0]= new SingleLineRule("'", "'", new Token(new TextAttribute(highlightColor))); //$NON-NLS-1$ //$NON-NLS-2$ + scanner.setRules(rules); + return scanner; + } + + private IDocumentPartitioner setupNamedPartitioning(Document document) { + IPartitionTokenScanner partitionScanner= new RuleBasedPartitionScanner(); + IDocumentPartitioner partitioner= new FastPartitioner(partitionScanner, new String[] {}); + document.setDocumentPartitioner(NAMED_PARTITIONING, partitioner); + partitioner.connect(document); + return partitioner; + } + + private IDocumentPartitioner setupDefaultPartitioning(Document document) { + IPartitionTokenScanner partitionScanner= new RuleBasedPartitionScanner(); + IDocumentPartitioner partitioner= new FastPartitioner(partitionScanner, new String[] {}); + document.setDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING, partitioner); + partitioner.connect(document); + return partitioner; + } +}