Skip to content

Commit

Permalink
Faster and slicker Semantic Highlighting
Browse files Browse the repository at this point in the history
The former implementation of semantic highlighting was slow becuase of the
following two reasons:

First, the annotations created by the semantic highlighting component, where
invalidated and recomputed every time the compilation unit was reconciled.

Second, for the current implementation of the AnnotationModel, adding/removing
an annotation is O(n^ 2). (The quadratic behavior is due to the implementation
of ``CompilationUnitDocumentProvider#ReverseMap``, which is used by
``CompilationUnitDocumentProvider#CompilationUnitAnnotationModel#addAnnotation``.
Basically, each time an annotation is added/removed, the ``ReverseMap`` class -
which is backed by a linked list - is accessed to verify if the element exist.)

The proposed solution is to apply semantic colorings directly on the editor's
``TextPresentation`` and hence no longer use the ``AnnotationModel``. This
approach is both lightweight and orders of magnitude more efficient than the
former.

The way semantic highlighting works now is relatively simple. At a high level,
there are only three key elements:

* ``Presenter$SemanticHighlightingJob``: Given a compilation unit, it
  classifyies and collects all positions that need to be colored in the editor.

* ``PositionsTracker``: Keeps track of currently highlighted positions in the
  editor.

* ``TextPresentationEditorHighlighter$ApplyHighlightingTextPresentationChange``:
  Converts them into   ``StyleRange``s, and applies the styles to the editor's
  ``TextPresentation``.

Of course the actual implementation contains a bit more than just the above
mentioned classes, but most of it is needed to correctly handle platform's
events such as the swapping of the document input (see
``Presenter$DocumentSwapListener`).

There is however one important optimization that is worth mentioning, which is
done to reduce to a minimum the editor's region whose styles need to be
refreshed. Basically, whenever the user is typing in the editor, all positions
that are **after** the cursor should be invalidated. If you are editing the
beginning of a big file, almost the whole editor's text presentation would need
to be refreshed. Furthermore, the mentioned approach would cause colors to
flicker because all highlighted positions **after** the edit would be deleted
(and hence the colors would be removed), and the new positions would get
colored only **after** the semantic highlighting job completes. This is clearly
not ideal, and it turns out it can be easily avoided by registering a
``IPositionUpdater`` on the editor's document. The concrete implementation of
the listener (i.e., ``Presenter$PositionUpdater``) takes care of shifting -
through side-effect - all currently highlighted positions that are **after**
the edited region. The net effect is that colors never flicker.

Bottom line, with the current implementation semantic highlighting is:

* Considerably faster.
* Never flickers.
* Testable.

If you wonder why the semantically highlighted positions are not stored in the
document's model, the answer is that I simply could not see any benefit of
doing so (why? because the document synchronizes access to the underlying
position's map when adding/removing positions).  Furthermore, it is really easy
to add the missing logic for storing the positions in the document's model, if
in the future it turns out we need/want to do so.

Fix #1001156

Other facts about this commit:

* Added the needed infrastructure for registering reconciling listeners (i.e.,
  ``IJavaReconcilingListener``) to the editor.

* Created a ``UiThread`` trait needed for testing code that needs to be run
  within the UI Thread.

Other tickets fixed by this commit:

* Disabling semantic highlighting in the preferences now refreshes currently
  opened editors. Fixes #1001507

* Changing semantic highlighting style's preferences now refreshes the
  currently opened editors. Fixes #1001508

* Cache highlighting styles instead of creating the during
  ``TextPresentationEditorHighlighter.ApplyHighlightingTextPresentationChanges.applyTextPresentation``.
  This optimization has major consequence on the user's experience. I should also
  point out that ``ScalaSyntaxClass`` and ``SymbolTypes``  are quite messy and
  should be improved. However, I think this should be tackled in a different PR.
  Fixes #1001493, #1001489.
(cherry picked from commit 9bfeb02)
  • Loading branch information
dotta committed Feb 21, 2013
1 parent 6a919f9 commit de75d9a
Show file tree
Hide file tree
Showing 46 changed files with 1,389 additions and 519 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import scala.tools.eclipse.sbtbuilder.ScalaJavaDepTest;
import scala.tools.eclipse.sbtbuilder.TodoBuilderTest;
import scala.tools.eclipse.semantic.ImplicitsHighlightingTest;
import scala.tools.eclipse.semantichighlighting.SemanticHighlightingPositionsTest;
import scala.tools.eclipse.semantichighlighting.classifier.SymbolClassifierTestSuite;
import scala.tools.eclipse.structurebuilder.StructureBuilderTest;
import scala.tools.eclipse.ui.UITestSuite;
Expand Down Expand Up @@ -66,6 +67,7 @@
ScalaJavaDepTest.class,
TodoBuilderTest.class,
ImplicitsHighlightingTest.class,
SemanticHighlightingPositionsTest.class,
SymbolClassifierTestSuite.class,
StructureBuilderTest.class,
UITestSuite.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package scala.tools.eclipse.semantichighlighting

import scala.tools.eclipse.EclipseUserSimulator
import scala.tools.eclipse.ScalaPlugin
import scala.tools.eclipse.ScalaProject
import scala.tools.eclipse.javaelements.ScalaCompilationUnit
import scala.tools.eclipse.jface.text.EmptyRegion
import scala.tools.eclipse.properties.syntaxcolouring.ScalaSyntaxClasses
import scala.tools.eclipse.semantichighlighting.classifier.SymbolTypes
import scala.tools.eclipse.ui.InteractiveCompilationUnitEditor
import scala.tools.eclipse.util.CurrentThread
import scala.tools.eclipse.util.EclipseUtils
import scala.util.matching.Regex
import scala.util.matching.Regex.Match

import org.eclipse.core.internal.filebuffers.SynchronizableDocument
import org.eclipse.core.runtime.IProgressMonitor
import org.eclipse.core.runtime.IStatus
import org.eclipse.core.runtime.NullProgressMonitor
import org.eclipse.core.runtime.jobs.Job
import org.eclipse.jface.preference.IPreferenceStore
import org.eclipse.jface.text.IDocument
import org.eclipse.jface.text.IRegion
import org.eclipse.jface.text.Region
import org.eclipse.jface.text.source.ISourceViewer
import org.junit.{ Before, Test }
import org.junit.After
import org.junit.Assert
import org.mockito.Mockito._

class SemanticHighlightingPositionsTest {
import SemanticHighlightingPositionsTest._

private val MarkerRegex: Regex = """/\*\^\*/""".r
private val Marker = "/*^*/"

protected val simulator = new EclipseUserSimulator
private var project: ScalaProject = _

private var sourceView: ISourceViewer = _
private var document: IDocument = _

private val preferences: Preferences = {
val store = mock(classOf[IPreferenceStore])
when(store.getBoolean(ScalaSyntaxClasses.USE_SYNTACTIC_HINTS)).thenReturn(true)
new Preferences(store)
}

private var editor: TextPresentationStub = _
private var presenter: Presenter = _

private var testCode: String = _
private var unit: ScalaCompilationUnit = _
private var compilationUnitEditor: InteractiveCompilationUnitEditor = mock(classOf[InteractiveCompilationUnitEditor])

@Before
def createProject() {
project = simulator.createProjectInWorkspace("semantic-highlighting-positions-update", true)
}

@After
def deleteProject() {
EclipseUtils.workspaceRunnableIn(ScalaPlugin.plugin.workspaceRoot.getWorkspace) { _ =>
project.underlying.delete(true, null)
}
}

@Before
def setupMocks() {
sourceView = mock(classOf[ISourceViewer])
document = new SynchronizableDocument
when(sourceView.getDocument()).thenReturn(document)
}

trait Edit {
def newText: String = ""
def newPositions: List[Position] = Nil
}
case class AddText(override val newText: String, override val newPositions: List[Position] = Nil) extends Edit
case object RemoveText extends Edit

private def setTestCode(code: String): Unit = {
testCode = code.stripMargin
val emptyPkg = simulator.createPackage("")
unit = simulator.createCompilationUnit(emptyPkg, "A.scala", testCode).asInstanceOf[ScalaCompilationUnit]
when(compilationUnitEditor.getInteractiveCompilationUnit).thenReturn(unit)
document.set(testCode)
}

private def createSemanticHighlightingPresenter(): Unit = {
editor = TextPresentationStub(sourceView)
presenter = new Presenter(compilationUnitEditor, editor, preferences, CurrentThread)
presenter.initialize(forceRefresh = false)
}

private def positionsInRegion(region: Region): List[Position] = {
val positions = editor.positionsTracker.positionsInRegion(region).filterNot(_.isDeleted())
// We clone the `positions` because the semantic highlighting component works by performing side-effects
// on the existing positions. And, the `runTest`, expects the returned positions to not change.
positions.map(pos => new Position(pos.getOffset, pos.getLength, pos.kind, pos.deprecated)).toList
}

private def findAllMarkersIn(code: String): List[Match] =
MarkerRegex.findAllIn(code).matchData.toList

/** Test that semantic highlighting positions are correctly created for the passed `code` before
* and after performing the `edit`. It also checks that the damaged region caused by the `edit`
* is the smallest contiguous region that includes all positions affected by the `edit`.
*
* The passed `code` should contain at least one, and at most two, `Marker`.
*
* - One `Marker`: the new text carried by the `edit` should replace the `Marker` in
* the passed `code`.
*
* - Two `Marker`'s: the new text carried by the `edit` should replace the whole text contained
* within the two `Marker`s in the passed `code`.
*
* @param edit The edit action to perform on the passed `code`.
* @param code The test code.
*/
private def runTest(edit: Edit)(code: String): Unit = {
setTestCode(code)

createSemanticHighlightingPresenter()

val highlightedBeforeEdition = positionsInRegion(new Region(0, code.length))

val markers = findAllMarkersIn(testCode)

val (start, end) = markers.size match {
case 1 => (markers.head.start, markers.head.end)
case 2 => (markers.head.start, markers.last.end)
case size => throw new AssertionError("Unsupported test definition. Found %d occurrences of %s in test code:\n%s".format(size, Marker, testCode))
}
val length = end - start

val positionShift = edit.newText.length - length
val expectedHighlightedPositionsAfterEdition: List[Position] = {
// collect all positions in the document that are expected to still be highlighted after the edition
val positions = highlightedBeforeEdition.filterNot { _.overlapsWith(start, length) }
// of the above positions, shift the ones that are affected by the edition
val shiftedPositions = positions.map { pos =>
if (pos.getOffset() >= start) pos.setOffset(pos.getOffset() + positionShift)
pos
}
// merge together the shifted positions and any additional position that is expected to be created by the edit
val merged = (edit.newPositions ++ positions)
// Sort the position. This is needed because position's added to the document are expected to be sorted.
val sorted = merged.sorted
sorted
}

def editTestCode(offset: Int, length: Int, newText: String): Unit = {
document.replace(start, length, edit.newText) // triggers the IUpdatePosition listener
unit.getBuffer().replace(start, length, edit.newText) // needed by the semantic highlighting reconciler
}

// perform edit
editTestCode(start, length, edit.newText)

// checks edit's postcondition
val currentTestCode = unit.getContents.mkString
if (findAllMarkersIn(currentTestCode).nonEmpty)
throw new AssertionError("After edition, no marker `%s` should be present in test code:\n%s".format(Marker, currentTestCode))

// This will trigger semantic highlighting computation, which in turn will update the document's positions (sequential execution!)
editor.reconcileNow()

val newPositions = positionsInRegion(new Region(start, edit.newText.length()))
val affectedRegion = PositionsChange(newPositions, Nil).affectedRegion()

val highlightedAfterEdition = positionsInRegion(new Region(0, currentTestCode.length))

Assert.assertEquals("Wrong start of damaged region", affectedRegion.getOffset(), editor.damagedRegion.getOffset())
Assert.assertEquals("Wrong length of damaged region", affectedRegion.getLength(), editor.damagedRegion.getLength())
Assert.assertEquals(expectedHighlightedPositionsAfterEdition.size, highlightedAfterEdition.size)
Assert.assertEquals(expectedHighlightedPositionsAfterEdition, highlightedAfterEdition)
}

@Test
def highlighted_positions_not_affected_by_edition() {
runTest(AddText("\n\n")) {
"""
|class A {
| def foo(): Unit = {}
| /*^*/
|}
"""
}
}

@Test
def existing_highlighted_positions_are_shifted() {
runTest(AddText("\n\n")) {
"""
|class A {
| /*^*/
| def foo(): Unit = {}
|}
"""
}
}

@Test
def new_highlighted_positions_are_reported() {
runTest(AddText("val bar = 2", List(new Position(17, 3, SymbolTypes.TemplateVal, deprecated = false)))) {
"""
|class A {
| /*^*/
| def foo(): Unit = {}
|}
"""
}
}

@Test
def highlighted_positions_in_the_document_are_removed_on_deletion() {
runTest(RemoveText) {
"""
|class A {
| /*^*/
| def foo(): Unit = {}
| /*^*/
|}
"""
}
}

@Test
def highlighted_positions_around_deletion_action_are_correct() {
runTest(RemoveText) {
"""
|class A {
|/*^*/
| if (true)
|/*^*/ println("abc")
|}
"""
}
}

@Test
def correctly_compute_damagedRegion_whenDeletingText() {
runTest(RemoveText) {
"""
|object A {
| /*^*/def f = 0
| /*^*/class C
|}
"""
}
}
}

object SemanticHighlightingPositionsTest {

class TextPresentationStub(override val sourceViewer: ISourceViewer) extends TextPresentationHighlighter {
@volatile private var reconciler: Job = _
@volatile var positionsTracker: PositionsTracker = _
@volatile var damagedRegion: IRegion = _

override def initialize(reconciler: Job, positionsTracker: PositionsTracker): Unit = {
this.reconciler = reconciler
this.positionsTracker = positionsTracker
reconcileNow()
}

override def dispose(): Unit = ()

def reconcileNow(): Unit = {
damagedRegion = EmptyRegion
// `Job.run` is protected, but when we subclass it in `Presenter$Reconciler` we make the `run` method public, which is really useful for running the reconciler within the same thread of the test.
reconciler.asInstanceOf[{ def run(monitor: IProgressMonitor): IStatus }].run(new NullProgressMonitor)
}

override def updateTextPresentation(damage: IRegion): Unit = damagedRegion = damage
}

object TextPresentationStub {
def apply(sourceViewer: ISourceViewer): TextPresentationStub = new TextPresentationStub(sourceViewer)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import scala.tools.eclipse.ScalaProject
import scala.tools.eclipse.util.EclipseUtils
import scala.tools.eclipse.ScalaPlugin
import org.junit.After
import org.eclipse.core.runtime.NullProgressMonitor
import org.eclipse.jface.text.IRegion
import scala.tools.eclipse.semantichighlighting.classifier.SymbolTypes.SymbolType

class AbstractSymbolClassifierTest {

import AbstractSymbolClassifierTest._

protected val simulator = new EclipseUserSimulator

private var project: ScalaProject = _
Expand All @@ -28,15 +32,15 @@ class AbstractSymbolClassifierTest {
}

protected def checkSymbolClassification(source: String, locationTemplate: String, regionTagToSymbolType: Map[String, SymbolType]) {
val expectedRegionToSymbolNameMap: Map[Region, String] = RegionParser.getRegions(locationTemplate)
val expectedRegionsAndSymbols: List[(Region, SymbolType)] =
val expectedRegionToSymbolNameMap: Map[IRegion, String] = RegionParser.getRegions(locationTemplate)
val expectedRegionsAndSymbols: List[(IRegion, SymbolType)] =
expectedRegionToSymbolNameMap.mapValues(regionTagToSymbolType).toList sortBy regionOffset
val actualRegionsAndSymbols: List[(Region, SymbolType)] =
val actualRegionsAndSymbols: List[(IRegion, SymbolType)] =
classifySymbols(source, expectedRegionToSymbolNameMap.keySet) sortBy regionOffset

if (expectedRegionsAndSymbols != actualRegionsAndSymbols) {
val sb = new StringBuffer
def displayRegions(regionToSymbolInfoMap: List[(Region, SymbolType)]) = {
def displayRegions(regionToSymbolInfoMap: List[(IRegion, SymbolType)]) = {
regionToSymbolInfoMap.toList.sortBy(regionOffset) map {
case (region, symbolType) =>
" " + region + " '" + region.of(source) + "' " + symbolType
Expand All @@ -51,7 +55,7 @@ class AbstractSymbolClassifierTest {
}
}

private def classifySymbols(source: String, restrictToRegions: Set[Region]): List[(Region, SymbolType)] = {
private def classifySymbols(source: String, restrictToRegions: Set[IRegion]): List[(IRegion, SymbolType)] = {
val sourceFile = new BatchSourceFile("", source)
project.withPresentationCompiler { compiler =>
// first load the source
Expand All @@ -60,7 +64,7 @@ class AbstractSymbolClassifierTest {
dummy.get

// then run classification
val symbolInfos: List[SymbolInfo] = SymbolClassifier.classifySymbols(sourceFile, compiler, useSyntacticHints = true)
val symbolInfos: List[SymbolInfo] = new SymbolClassification(sourceFile, compiler, useSyntacticHints = true).classifySymbols(new NullProgressMonitor)
for {
SymbolInfo(symbolType, regions, deprecated) <- symbolInfos
region <- regions
Expand All @@ -69,6 +73,16 @@ class AbstractSymbolClassifierTest {
}(orElse = Nil)
}.distinct sortBy regionOffset

private def regionOffset(regionAndSymbolType: (Region, SymbolType)) = regionAndSymbolType._1.offset
private def regionOffset(regionAndSymbolType: (IRegion, SymbolType)) = regionAndSymbolType._1.getOffset
}

object AbstractSymbolClassifierTest {
private class RegionOps(region: IRegion) {
def intersects(other: IRegion): Boolean =
!(other.getOffset >= region.getOffset + region.getLength || other.getOffset + other.getLength - 1 < region.getOffset)

def of(s: String): String = s.slice(region.getOffset, region.getOffset + region.getLength)
}

private implicit def region2regionOps(region: IRegion): RegionOps = new RegionOps(region)
}
Loading

0 comments on commit de75d9a

Please sign in to comment.