/*
* Copyright 2003-2016 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jetbrains.mps.nodeEditor;
import com.intellij.openapi.application.ApplicationAdapter;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandEvent;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.components.ProjectComponent;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.project.DumbService;
import com.intellij.openapi.project.IndexNotReadyException;
import com.intellij.openapi.project.Project;
import com.intellij.util.concurrency.EdtExecutorService;
import com.intellij.util.messages.MessageBusConnection;
import jetbrains.mps.RuntimeFlags;
import jetbrains.mps.classloading.ClassLoaderManager;
import jetbrains.mps.classloading.MPSClassesListener;
import jetbrains.mps.classloading.MPSClassesListenerAdapter;
import jetbrains.mps.ide.MPSCoreComponents;
import jetbrains.mps.ide.ThreadUtils;
import jetbrains.mps.make.IMakeService;
import jetbrains.mps.module.ReloadableModuleBase;
import jetbrains.mps.nodeEditor.checking.EditorChecker;
import jetbrains.mps.nodeEditor.highlighter.EditorCheckerWrapper;
import jetbrains.mps.nodeEditor.highlighter.EditorComponentCreateListener;
import jetbrains.mps.nodeEditor.highlighter.HighlighterEditorList;
import jetbrains.mps.nodeEditor.highlighter.HighlighterEditorTracker;
import jetbrains.mps.nodeEditor.highlighter.HighlighterEventCollector;
import jetbrains.mps.nodeEditor.highlighter.HighlighterUpdateSession;
import jetbrains.mps.nodeEditor.highlighter.IHighlighter;
import jetbrains.mps.openapi.editor.Editor;
import jetbrains.mps.openapi.editor.message.EditorMessageOwner;
import jetbrains.mps.project.MPSProject;
import jetbrains.mps.smodel.GlobalSModelEventsManager;
import jetbrains.mps.smodel.event.SModelEvent;
import jetbrains.mps.smodel.event.SModelReplacedEvent;
import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.mps.openapi.model.SModelReference;
import org.jetbrains.mps.openapi.repository.CommandListener;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class Highlighter implements IHighlighter, ProjectComponent {
private static final Logger LOG = LogManager.getLogger(Highlighter.class);
private static final int DEFAULT_GRACE_PERIOD = 150;
public static final int DEFAULT_DELAY_MULTIPLIER = 1;
private volatile boolean myPaused;
private final ApplicationAdapter myApplicationListener = new PauseDuringWriteAction();
private final com.intellij.openapi.command.CommandAdapter myCommandListener = new PauseDuringCommandOrUndoTransparentAction();
private GlobalSModelEventsManager myGlobalSModelEventsManager;
private ClassLoaderManager myClassLoaderManager;
private ScheduledExecutorService myBackgroundExecutor;
private ScheduleHighlighterUpdate myScheduleHighlighterUpdate;
private final List<EditorCheckerWrapper> myCheckers = new CopyOnWriteArrayList<>();
/**
* Whether to force running all checkers in power-save mode. Accessed from the highlighter thread only, therefore non-volatile.
*/
private boolean myForceUpdateInPowerSaveModeFlag = false;
private InspectorTool myInspectorTool;
private MessageBusConnection myMessageBusConnection;
private MPSClassesListener myClassesListener = new MPSClassesListenerAdapter() {
@Override
public void beforeClassesUnloaded(Set<? extends ReloadableModuleBase> modules) {
addPendingAction(new Runnable() {
@Override
public void run() {
myEditorTracker.markEverythingUnchecked();
myEditorList.clearAdditionalEditors();
}
});
}
};
private final Project myProject;
private final MPSProject myMPSProject;
private CommandWatcher myCommandWatcher = new CommandWatcher();
private final HighlighterEditorList myEditorList;
private final HighlighterEventCollector myEventCollector = new HighlighterEventCollector();
// Keeps track of which editors may be checked incrementally. Must only be accessed from the highlighter background thread.
private final HighlighterEditorTracker myEditorTracker = new HighlighterEditorTracker();
/*
* MPSProject was used as a parameter of this constructor because corresponding component should be initialised after
* MPSProject and un-initialized before it.
*/
public Highlighter(MPSProject mpsProject, Project project, FileEditorManager fileEditorManager, InspectorTool inspector, MPSCoreComponents coreComponents) {
myMPSProject = mpsProject;
myProject = project;
myEditorList = new HighlighterEditorList(fileEditorManager);
myGlobalSModelEventsManager = coreComponents.getGlobalSModelEventsManager();
myClassLoaderManager = coreComponents.getClassLoaderManager();
myInspectorTool = inspector;
}
@Override
public void projectOpened() {
myClassLoaderManager.addClassesHandler(myClassesListener);
myEventCollector.startListening(myGlobalSModelEventsManager, myMPSProject.getRepository());
myInspectorTool = myProject.getComponent(InspectorTool.class);
myMessageBusConnection = myProject.getMessageBus().connect();
myMessageBusConnection.subscribe(EditorComponentCreateListener.EDITOR_COMPONENT_CREATION, new EditorComponentCreateListener() {
@Override
public void editorComponentCreated(@NotNull EditorComponent editorComponent) {
}
@Override
public void editorComponentDisposed(@NotNull final EditorComponent editorComponent) {
if (myEditorTracker.isInspector(editorComponent)) {
addPendingAction(new Runnable() {
@Override
public void run() {
myEditorTracker.markInspectorUnchecked();
}
});
}
}
});
ApplicationManager.getApplication().addApplicationListener(myApplicationListener);
CommandProcessor.getInstance().addCommandListener(myCommandListener);
myMPSProject.getModelAccess().addCommandListener(myCommandWatcher);
}
@Override
public void projectClosed() {
myMPSProject.getModelAccess().removeCommandListener(myCommandWatcher);
CommandProcessor.getInstance().removeCommandListener(myCommandListener);
ApplicationManager.getApplication().removeApplicationListener(myApplicationListener);
myEventCollector.stopListening(myGlobalSModelEventsManager, myMPSProject.getRepository());
myClassLoaderManager.removeClassesHandler(myClassesListener);
myMessageBusConnection.disconnect();
myInspectorTool = null;
}
@Override
@NonNls
@NotNull
public String getComponentName() {
return "MPS Highlighter";
}
@Override
public void initComponent() {
startUpdater();
}
@Override
public void disposeComponent() {
stopUpdater();
}
private Future<?> addPendingAction(Runnable action) {
return myBackgroundExecutor.submit(action);
}
private <T> Future<T> addPendingAction(Callable<T> action) {
return myBackgroundExecutor.submit(action);
}
public void addChecker(@NotNull final EditorChecker checker) {
if (RuntimeFlags.isTestMode()) {
return;
}
addPendingAction(new Runnable() {
@Override
public void run() {
myCheckers.add(new EditorCheckerWrapper(checker));
myEditorTracker.markEverythingUnchecked();
}
});
}
/**
* Removes a checker from the set of active checkers. Also removes its messages from any known open editors. Must be called from EDT.
*
* @param checker the checker to remove
*/
public void removeChecker(@NotNull final EditorChecker checker) {
if (RuntimeFlags.isTestMode()) {
return;
}
ThreadUtils.assertEDT();
// Checker removal is done in three steps:
//
// 1. Remove the checker's wrapper from the internal list of checkers and stop it.
EditorCheckerWrapper wrapper = findWrapperFor(checker);
if (wrapper == null) {
return;
}
myCheckers.remove(wrapper);
EditorMessageOwner messageOwner = wrapper.getEditorMessageOwner();
// After dispose() completes it is guaranteed that the highlighter will not run the checker in the background anymore.
wrapper.stop();
// 2. In EDT (since UI access is required) get a list of all editors that are currently open.
final List<EditorComponent> editors = myEditorList.getAllEditors();
if (editors.isEmpty()) {
return;
}
// 3. In the highlighter thread again (actually, any background thread would do), go through the list retrieved in the previous step and remove
// the checker's messages from each editor.
addPendingAction(new Runnable() {
@Override
public void run() {
long time = System.currentTimeMillis();
for (EditorComponent component : editors) {
component.getHighlightManager().clearForOwner(messageOwner, true);
}
if (LOG.isDebugEnabled()) {
long elapsed = System.currentTimeMillis() - time;
LOG.debug(String.format("Removing %s messages from %d editors took %d ms", messageOwner, editors.size(), elapsed));
}
}
});
}
@Nullable
private EditorCheckerWrapper findWrapperFor(@NotNull EditorChecker checker) {
EditorCheckerWrapper wrapper = null;
for (EditorCheckerWrapper candidate : myCheckers) {
if (candidate.isWrapping(checker)) {
wrapper = candidate;
break;
}
}
return wrapper;
}
public void addAdditionalEditorComponent(EditorComponent additionalEditorComponent) {
myEditorList.addAdditionalEditorComponent(additionalEditorComponent);
}
public void removeAdditionalEditorComponent(EditorComponent additionalEditorComponent) {
myEditorList.removeAdditionalEditorComponent(additionalEditorComponent);
}
public void addAdditionalEditor(Editor additionalEditor) {
myEditorList.addAdditionalEditor(additionalEditor);
}
public void removeAdditionalEditor(Editor additionalEditor) {
myEditorList.removeAdditionalEditor(additionalEditor);
}
private void startUpdater() {
if (myBackgroundExecutor != null && !myBackgroundExecutor.isShutdown()) {
LOG.error("trying to initialize a Highlighter being already initialized", new Throwable());
return;
}
myBackgroundExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> new Thread(runnable, "Highlighter"));
myScheduleHighlighterUpdate = new ScheduleHighlighterUpdate(EdtExecutorService.getScheduledExecutorInstance(), DumbService.getInstance(myProject));
if (!RuntimeFlags.isTestMode()) {
myScheduleHighlighterUpdate.scheduleNext();
}
}
public void stopUpdater() {
myScheduleHighlighterUpdate = null;
myBackgroundExecutor.shutdown();
try {
myBackgroundExecutor.awaitTermination(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
LOG.error(null, e);
}
}
@NotNull
private HighlighterUpdateSession createUpdateSession(List<EditorComponent> activeEditors, boolean essentialOnly) {
processAccumulatedEvents();
final Set<EditorCheckerWrapper> checkers = new LinkedHashSet<>();
if (!EditorSettings.getInstance().isPowerSaveMode() || myForceUpdateInPowerSaveModeFlag) {
// calling checkers only if we are not in powerSafeMode or updateEditorFlag was set by
// explicit update action (available in powerSafeMode only)
for (EditorCheckerWrapper checker : myCheckers) {
if (checker.isEssential() || !essentialOnly) {
checkers.add(checker);
}
}
checkers.addAll(myCheckers);
myForceUpdateInPowerSaveModeFlag = false;
}
if (EditorSettings.getInstance().isPowerSaveMode()) {
// if we are in powerSaveMode then next editor checkers execution should
// recheck all editors completely
myEditorTracker.markEverythingUnchecked();
} else {
myEditorTracker.markOnlyEditorsChecked(activeEditors);
}
return new HighlighterUpdateSession(Highlighter.this, checkers, activeEditors, getInspector());
}
public void resetCheckedStateInBackground(final EditorComponent editorComponent) {
addPendingAction(new Runnable() {
@Override
public void run() {
myForceUpdateInPowerSaveModeFlag = true;
myEditorTracker.markUnchecked(editorComponent);
if (myEditorTracker.isInspector(editorComponent)) {
return;
}
for (EditorCheckerWrapper checker : myCheckers) {
checker.forceAutofix(editorComponent);
}
}
});
}
@Override
public boolean isStopping() {
return myBackgroundExecutor.isShutdown();
}
@NotNull
@Override
public HighlighterEditorTracker getEditorTracker() {
return myEditorTracker;
}
private EditorComponent getInspector() {
if (myInspectorTool == null) {
return null;
}
return myInspectorTool.getInspector();
}
/**
* Feeds events collected at this point to all registered checkers for processing. Must be called on the highlighter thread because the collection of all
* checkers is accessed.
*/
private void processAccumulatedEvents() {
List<SModelEvent> events = myEventCollector.drainEvents();
for (SModelEvent event : events) {
if (event instanceof SModelReplacedEvent) {
final SModelReference mref = event.getModel().getReference();
myEditorTracker.markEditorsOfModelUnchecked(mref);
}
}
for (EditorCheckerWrapper checker : myCheckers) {
checker.processEvents(events);
}
}
private void pauseUpdater() {
myPaused = true;
}
private void resumeUpdater() {
myPaused = false;
}
@Override
public boolean isPausedOrStopping() {
return myPaused || isStopping();
}
private class PauseDuringWriteAction extends ApplicationAdapter {
@Override
public void beforeWriteActionStart(@NotNull Object action) {
pauseUpdater();
}
@Override
public void writeActionFinished(@NotNull Object action) {
resumeUpdater();
}
}
private class PauseDuringCommandOrUndoTransparentAction extends com.intellij.openapi.command.CommandAdapter {
private int myLevel = 0;
@Override
public void commandStarted(CommandEvent event) {
increaseLevel();
}
@Override
public void commandFinished(CommandEvent event) {
decreaseLevel();
}
@Override
public void undoTransparentActionStarted() {
increaseLevel();
}
@Override
public void undoTransparentActionFinished() {
decreaseLevel();
}
private void increaseLevel() {
if (myLevel == 0) {
pauseUpdater();
}
myLevel++;
}
private void decreaseLevel() {
myLevel--;
if (myLevel == 0) {
resumeUpdater();
}
}
}
/**
* Runs in EDT
*/
private class ScheduleHighlighterUpdate implements Runnable {
private final ScheduledExecutorService myEdtExecutor;
private final DumbService myDumbService;
public ScheduleHighlighterUpdate(ScheduledExecutorService edtExecutor, DumbService dumbService) {
myEdtExecutor = edtExecutor;
myDumbService = dumbService;
}
@Override
public void run() {
if (!isGoodTimeToUpdate()) {
if (!isStopping()) {
scheduleNext();
}
return;
}
List<EditorComponent> activeEditors = myEditorList.getActiveEditors(); // Must be called in EDT
myBackgroundExecutor.submit(() -> update(activeEditors));
}
private boolean isGoodTimeToUpdate() {
return !isPausedOrStopping() && !myDumbService.isDumb() && !IMakeService.INSTANCE.isSessionActive() && myCommandWatcher.isGracePeriodExpired();
}
private void update(List<EditorComponent> activeEditors) {
try {
createUpdateSession(activeEditors, shouldOnlyUpdateEssentialCheckers()).update();
} catch (IndexNotReadyException ex) {
myEditorTracker.markEverythingUnchecked();
} finally {
scheduleNext();
}
}
private void scheduleNext() {
myEdtExecutor.schedule(this, Math.max(myCommandWatcher.timeToExpiration(), DEFAULT_GRACE_PERIOD), TimeUnit.MILLISECONDS);
}
private boolean shouldOnlyUpdateEssentialCheckers() {
boolean essentialOnly;
if (myCommandWatcher.isLargerGracePeriodExpired()) {
myCommandWatcher.resetGracePeriod();
essentialOnly = false;
} else {
essentialOnly = true;
}
return essentialOnly;
}
}
/**
* Thread safe.
*/
private static class CommandWatcher implements CommandListener {
private AtomicLong myLastCommandStarted = new AtomicLong(System.currentTimeMillis());
private AtomicLong myLastCommandFinished = new AtomicLong(System.currentTimeMillis());
private AtomicLong myGracePeriod = new AtomicLong(DEFAULT_GRACE_PERIOD);
private AtomicInteger myCurrentMultiplier = new AtomicInteger(4);
boolean isGracePeriodExpired() {
final long time = System.currentTimeMillis();
long delta = time - myLastCommandFinished.get();
return delta >= myGracePeriod.get();
}
boolean isLargerGracePeriodExpired() {
final long time = System.currentTimeMillis();
long delta = time - myLastCommandFinished.get();
return delta - 2 * DEFAULT_GRACE_PERIOD >= myGracePeriod.get();
}
void resetGracePeriod() {
myGracePeriod.set(DEFAULT_GRACE_PERIOD);
myCurrentMultiplier.set(DEFAULT_DELAY_MULTIPLIER);
}
long timeToExpiration() {
final long time = System.currentTimeMillis();
final long delta = time - myLastCommandFinished.get();
final long left = myGracePeriod.get() - delta;
return Math.max(left, 0L);
}
@Override
public void commandStarted() {
final long time = System.currentTimeMillis();
myLastCommandStarted.set(time);
final long delta = time - myLastCommandFinished.get();
if (delta < myGracePeriod.get()) {
final int mult = myCurrentMultiplier.get();
myGracePeriod.getAndAdd(delta * mult);
myCurrentMultiplier.set(Math.max(mult - 1, 0));
}
}
@Override
public void commandFinished() {
final long time = System.currentTimeMillis();
myLastCommandFinished.set(time);
}
}
}