| /******************************************************************************* |
| * Copyright (c) 2014, 2018 1C-Soft LLC and others. |
| * |
| * This program and the accompanying materials are made available under |
| * the terms of the Eclipse Public License 2.0 which is available at |
| * https://www.eclipse.org/legal/epl-2.0/ |
| * |
| * SPDX-License-Identifier: EPL-2.0 |
| * |
| * Contributors: |
| * Vladimir Piskarev (1C) - initial API and implementation |
| *******************************************************************************/ |
| package org.eclipse.handly.xtext.ui.editor; |
| |
| import org.eclipse.core.runtime.IProgressMonitor; |
| import org.eclipse.core.runtime.ISafeRunnable; |
| import org.eclipse.core.runtime.ListenerList; |
| import org.eclipse.core.runtime.NullProgressMonitor; |
| import org.eclipse.core.runtime.OperationCanceledException; |
| import org.eclipse.core.runtime.SafeRunner; |
| import org.eclipse.core.runtime.SubMonitor; |
| import org.eclipse.emf.common.util.WrappedException; |
| import org.eclipse.handly.internal.xtext.ui.Activator; |
| import org.eclipse.handly.snapshot.DocumentSnapshot; |
| import org.eclipse.handly.snapshot.ISnapshot; |
| import org.eclipse.handly.snapshot.NonExpiringSnapshot; |
| import org.eclipse.handly.text.DocumentChange; |
| import org.eclipse.handly.text.DocumentChangeOperation; |
| import org.eclipse.handly.text.IDocumentChange; |
| import org.eclipse.handly.text.UiDocumentChangeRunner; |
| import org.eclipse.handly.util.UiSynchronizer; |
| import org.eclipse.jface.text.BadLocationException; |
| import org.eclipse.jface.text.DocumentEvent; |
| import org.eclipse.jface.text.IDocumentListener; |
| import org.eclipse.text.edits.TextEdit; |
| import org.eclipse.xtext.EcoreUtil2; |
| import org.eclipse.xtext.resource.XtextResource; |
| import org.eclipse.xtext.service.OperationCanceledError; |
| import org.eclipse.xtext.ui.editor.DirtyStateEditorSupport; |
| import org.eclipse.xtext.ui.editor.model.DocumentTokenSource; |
| import org.eclipse.xtext.ui.editor.model.IXtextDocumentContentObserver.Processor; |
| import org.eclipse.xtext.ui.editor.model.XtextDocument; |
| import org.eclipse.xtext.ui.editor.model.edit.ITextEditComposer; |
| import org.eclipse.xtext.ui.editor.reconciler.ReplaceRegion; |
| import org.eclipse.xtext.util.CancelIndicator; |
| import org.eclipse.xtext.util.concurrent.IUnitOfWork; |
| |
| import com.google.inject.Inject; |
| |
| /** |
| * Extends {@link XtextDocument} for Handly reconciling story. |
| * Implements {@link IHandlyXtextDocument}. |
| * <p> |
| * Bind this class in place of the default <code>XtextDocument</code> if you |
| * have {@link HandlyXtextEditorCallback} configured. Note that if you bind |
| * this class, you should also bind other classes pertaining to Handly/Xtext |
| * integration: |
| * </p> |
| * <pre> |
| * public Class<? extends XtextDocument> bindXtextDocument() { |
| * return HandlyXtextDocument.class; |
| * } |
| * |
| * public Class<? extends IReconciler> bindIReconciler() { |
| * return HandlyXtextReconciler.class; |
| * } |
| * |
| * public Class<? extends DirtyStateEditorSupport> bindDirtyStateEditorSupport() { |
| * return HandlyDirtyStateEditorSupport.class; // or its subclass |
| * }</pre> |
| * |
| * @noextend This class is not intended to be extended by clients. |
| */ |
| public class HandlyXtextDocument |
| extends XtextDocument |
| implements IHandlyXtextDocument |
| { |
| private final static IUnitOfWork.Void<XtextResource> NO_OP = |
| new IUnitOfWork.Void<XtextResource>() |
| { |
| public void process(XtextResource resource) |
| { |
| } |
| }; |
| |
| private final BooleanThreadLocal hasTopLevelModification = |
| new BooleanThreadLocal(); |
| |
| private ITextEditComposer composer2; // unfortunately had to duplicate |
| private volatile NonExpiringSnapshot reconciledSnapshot; |
| private volatile boolean reconcilingWasCanceled; |
| private final ThreadLocal<IProgressMonitor> reconcilingMonitor = |
| new ThreadLocal<IProgressMonitor>(); |
| private final ListenerList<IReconcilingListener> reconcilingListeners = |
| new ListenerList<>(ListenerList.IDENTITY); |
| private final DocumentListener selfListener = new DocumentListener(); |
| private PendingChange pendingChange; |
| private final Object pendingChangeLock = new Object(); |
| private DirtyStateEditorSupport dirtyStateEditorSupport; |
| |
| @Inject |
| public HandlyXtextDocument(DocumentTokenSource tokenSource, |
| ITextEditComposer composer) |
| { |
| super(tokenSource, composer); |
| this.composer2 = composer; |
| addReconcilingListener(new PostReconcileProcessor()); |
| } |
| |
| @Override |
| public void setInput(XtextResource resource) |
| { |
| super.setInput(resource); |
| reconciledSnapshot = getNonExpiringSnapshot(); // initial snapshot |
| addDocumentListener(selfListener); |
| } |
| |
| @Override |
| public void disposeInput() |
| { |
| super.disposeInput(); // sets resource to null under the document's write lock |
| // After this, T2MReconcilingUnitOfWork and M2TReconcilingUnitOfWork for the document |
| // are guaranteed to be passed null resource, which effectively causes them to become noop. |
| |
| removeDocumentListener(selfListener); |
| getAndResetPendingChange(); |
| reconciledSnapshot = null; |
| reconcilingListeners.clear(); |
| } |
| |
| @Override |
| public ISnapshot getReconciledSnapshot() |
| { |
| NonExpiringSnapshot reconciledSnapshot = this.reconciledSnapshot; |
| if (reconciledSnapshot == null) |
| return null; |
| return reconciledSnapshot.getWrappedSnapshot(); |
| } |
| |
| @Override |
| public boolean needsReconciling() |
| { |
| ISnapshot reconciledSnapshot = getReconciledSnapshot(); |
| if (reconciledSnapshot == null) |
| return false; |
| return !getSnapshot().isEqualTo(reconciledSnapshot) |
| || reconcilingWasCanceled; |
| } |
| |
| @Override |
| public void reconcile(boolean force, IProgressMonitor monitor) |
| { |
| reconcilingMonitor.set(monitor); |
| try |
| { |
| if (!force) |
| { |
| readOnly(NO_OP); |
| } |
| else |
| { |
| T2MReconcilingUnitOfWork reconcilingUnitOfWork = |
| new T2MReconcilingUnitOfWork(true); |
| internalModify(reconcilingUnitOfWork); |
| } |
| |
| if (monitor != null && monitor.isCanceled()) |
| throw new OperationCanceledException(); |
| } |
| finally |
| { |
| reconcilingMonitor.remove(); |
| } |
| } |
| |
| /** |
| * Reconciles the document model with changes in the document text. |
| * Does nothing and returns <code>false</code> if the document model |
| * is already in sync with the document text. |
| * |
| * @param processor the processor to execute the reconciling unit of work |
| * (not <code>null</code>) |
| * @return <code>true</code> if the document had any changes to be reconciled, |
| * and <code>false</code> otherwise |
| */ |
| boolean reconcile(Processor processor) |
| { |
| T2MReconcilingUnitOfWork reconcilingUnitOfWork = |
| new T2MReconcilingUnitOfWork(false); |
| return processor.process(reconcilingUnitOfWork); |
| } |
| |
| @Override |
| public <T> T modify(IUnitOfWork<T, XtextResource> work) |
| { |
| if (!hasTopLevelModification.get()) // wrap top-level modification (only) |
| work = new M2TReconcilingUnitOfWork<T>(work); |
| return internalModify(work); |
| } |
| |
| @Override |
| public IDocumentChange applyChange(IDocumentChange change) |
| throws BadLocationException |
| { |
| DocumentChangeOperation operation = new DocumentChangeOperation(this, |
| change); |
| UiDocumentChangeRunner runner = new UiDocumentChangeRunner( |
| UiSynchronizer.getDefault(), operation); |
| return runner.run(); |
| } |
| |
| void setDirtyStateEditorSupport( |
| DirtyStateEditorSupport dirtyStateEditorSupport) |
| { |
| this.dirtyStateEditorSupport = dirtyStateEditorSupport; |
| } |
| |
| void addReconcilingListener(IReconcilingListener listener) |
| { |
| reconcilingListeners.add(listener); |
| } |
| |
| void removeReconcilingListener(IReconcilingListener listener) |
| { |
| reconcilingListeners.remove(listener); |
| } |
| |
| private PendingChange getAndResetPendingChange() |
| { |
| final PendingChange result; |
| synchronized (pendingChangeLock) |
| { |
| result = pendingChange; |
| pendingChange = null; |
| } |
| return result; |
| } |
| |
| private void handleDocumentChanged(DocumentEvent event) |
| { |
| synchronized (pendingChangeLock) |
| { |
| if (pendingChange == null) |
| { |
| pendingChange = new PendingChange(); |
| } |
| pendingChange.add(event); |
| } |
| } |
| |
| private ISnapshot getSnapshot() |
| { |
| return new DocumentSnapshot(this); |
| } |
| |
| private NonExpiringSnapshot getNonExpiringSnapshot() |
| { |
| return new NonExpiringSnapshot(this::getSnapshot); |
| } |
| |
| /* |
| * Called just after a reconciling operation has been performed. Informs |
| * that the document's XtextResource contents is based on the given snapshot. |
| * Notifies reconciling listeners (if any). Should only be called |
| * in the dynamic context of {@link XtextDocument#internalModify}. |
| * |
| * @param resource the reconciled resource (never <code>null</code>) |
| * @param snapshot the reconciled snapshot (never <code>null</code>) |
| * @param forced whether reconciling was forced, i.e. the document has not |
| * changed since it was reconciled the last time |
| * @param monitor a progress monitor (never <code>null</code>). |
| * The caller must not rely on {@link IProgressMonitor#done()} |
| * having been called by the receiver |
| * @throws OperationCanceledException if this method is canceled |
| */ |
| private void reconciled(XtextResource resource, |
| NonExpiringSnapshot snapshot, boolean forced, IProgressMonitor monitor) |
| { |
| Object[] listeners = reconcilingListeners.getListeners(); |
| SubMonitor loopMonitor = SubMonitor.convert(monitor, listeners.length); |
| for (Object listener : listeners) |
| { |
| SubMonitor iterationMonitor = loopMonitor.split(1); |
| SafeRunner.run(new ISafeRunnable() |
| { |
| @Override |
| public void run() throws Exception |
| { |
| ((IReconcilingListener)listener).reconciled(resource, |
| snapshot, forced, iterationMonitor); |
| } |
| |
| @Override |
| public void handleException(Throwable exception) |
| { |
| } |
| }); |
| } |
| } |
| |
| private void internalReconcile(XtextResource resource) |
| { |
| reconcile(new InternalProcessor(resource)); |
| } |
| |
| private IProgressMonitor getReconcilingMonitor() |
| { |
| IProgressMonitor monitor = reconcilingMonitor.get(); |
| if (monitor == null) |
| monitor = new NullProgressMonitor(); |
| return monitor; |
| } |
| |
| /** |
| * Document reconciling listener protocol. |
| */ |
| interface IReconcilingListener |
| { |
| /** |
| * Called just after a reconciling operation has been performed. Informs |
| * that the given resource contents is based on the given snapshot. |
| * <p> |
| * Implementations of this method must not modify the resource and |
| * must not keep any references to it. The resource is safe to read |
| * in the dynamic context of the method call. The resource has bindings |
| * to text positions in the given snapshot. |
| * </p> |
| * <p> |
| * An exception thrown by this method will be logged (except for |
| * OperationCanceledException) and suppressed. |
| * </p> |
| * |
| * @param resource the reconciled resource (never <code>null</code>) |
| * @param snapshot the reconciled snapshot (never <code>null</code>) |
| * @param forced whether reconciling was forced, i.e. the document |
| * has not changed since it was reconciled the last time |
| * @param monitor a progress monitor (never <code>null</code>). |
| * The caller must not rely on {@link IProgressMonitor#done()} |
| * having been called by the receiver |
| * @throws OperationCanceledException if this method is canceled |
| * @throws Exception if a problem occurred while running this method |
| */ |
| void reconciled(XtextResource resource, NonExpiringSnapshot snapshot, |
| boolean forced, IProgressMonitor monitor) throws Exception; |
| } |
| |
| private class DocumentListener |
| implements IDocumentListener |
| { |
| @Override |
| public void documentAboutToBeChanged(DocumentEvent event) |
| { |
| } |
| |
| @Override |
| public void documentChanged(DocumentEvent event) |
| { |
| handleDocumentChanged(event); |
| } |
| } |
| |
| private class PendingChange |
| { |
| private NonExpiringSnapshot snapshotToReconcile; |
| private ReplaceRegion replaceRegionToReconcile; |
| private long modificationStamp; |
| |
| public NonExpiringSnapshot getSnapshotToReconcile() |
| { |
| return snapshotToReconcile; |
| } |
| |
| public ReplaceRegion getReplaceRegionToReconcile() |
| { |
| return replaceRegionToReconcile; |
| } |
| |
| public long getModificationStamp() |
| { |
| return modificationStamp; |
| } |
| |
| /** |
| * Should be called immediately <b>after</b> document change. |
| * |
| * @param event describes how the document changed |
| */ |
| public void add(DocumentEvent event) |
| { |
| snapshotToReconcile = getNonExpiringSnapshot(); |
| ReplaceRegion replaceRegion = new ReplaceRegion(event.getOffset(), |
| event.getLength(), event.getText()); |
| if (replaceRegionToReconcile == null) |
| replaceRegionToReconcile = replaceRegion; |
| else |
| replaceRegionToReconcile.mergeWith(replaceRegion, |
| snapshotToReconcile.getContents()); |
| modificationStamp = event.getModificationStamp(); |
| } |
| } |
| |
| /* |
| * Should only be called when the document's write lock is held. |
| */ |
| private class T2MReconcilingUnitOfWork |
| implements IUnitOfWork<Boolean, XtextResource> |
| { |
| private final boolean force; |
| |
| public T2MReconcilingUnitOfWork(boolean force) |
| { |
| this.force = force; |
| } |
| |
| /* |
| * This method uses a thread-local reconciling monitor to support |
| * cancellation and report progress. If this method is canceled, |
| * it does NOT throw OperationCanceledException. Rather, it |
| * makes a note that reconciling was canceled and returns. |
| */ |
| @Override |
| public Boolean exec(XtextResource resource) throws Exception |
| { |
| SubMonitor subMonitor = SubMonitor.convert(getReconcilingMonitor(), |
| 2); |
| |
| if (resource == null) // input not set or already disposed |
| { |
| if (force) |
| throw new NoXtextResourceException( |
| HandlyXtextDocument.this); |
| return false; |
| } |
| |
| if (subMonitor.isCanceled()) |
| { |
| reconcilingWasCanceled = true; |
| return false; |
| } |
| |
| PendingChange change = getAndResetPendingChange(); |
| if (change == null) |
| { |
| if (force || reconcilingWasCanceled) |
| { |
| NonExpiringSnapshot snapshot = reconciledSnapshot; |
| if (force) // no need to reparse -- just update internal state |
| resource.update(0, 0, ""); //$NON-NLS-1$ |
| SafeRunner.run(new ISafeRunnable() |
| { |
| @Override |
| public void run() throws Exception |
| { |
| subMonitor.worked(1); |
| reconciled(resource, snapshot, |
| !reconcilingWasCanceled, subMonitor.split(1)); |
| } |
| |
| @Override |
| public void handleException(Throwable exception) |
| { |
| // the exception is suppressed, including OCE |
| } |
| }); |
| reconcilingWasCanceled = subMonitor.isCanceled(); |
| } |
| return false; |
| } |
| else |
| { |
| NonExpiringSnapshot snapshot = change.getSnapshotToReconcile(); |
| ReplaceRegion replaceRegion = |
| change.getReplaceRegionToReconcile(); |
| long modificationStamp = change.getModificationStamp(); |
| try |
| { |
| resource.update(replaceRegion.getOffset(), |
| replaceRegion.getLength(), replaceRegion.getText()); |
| resource.setModificationStamp(modificationStamp); |
| } |
| catch (Exception e) |
| { |
| // partial parsing failed - performing full reparse |
| Activator.log(Activator.createErrorStatus(e.getMessage(), |
| e)); |
| try |
| { |
| resource.reparse(snapshot.getContents()); |
| resource.setModificationStamp(modificationStamp); |
| } |
| catch (Exception e2) |
| { |
| // full parsing also failed - restore state |
| Activator.log(Activator.createErrorStatus( |
| e2.getMessage(), e2)); |
| resource.reparse(reconciledSnapshot.getContents()); |
| throw e2; |
| } |
| } |
| SafeRunner.run(new ISafeRunnable() |
| { |
| @Override |
| public void run() throws Exception |
| { |
| subMonitor.worked(1); |
| reconciled(resource, snapshot, false, subMonitor.split( |
| 1)); |
| } |
| |
| @Override |
| public void handleException(Throwable exception) |
| { |
| // the exception is suppressed, including OCE |
| } |
| }); |
| reconcilingWasCanceled = subMonitor.isCanceled(); |
| reconciledSnapshot = snapshot; |
| return true; |
| } |
| } |
| } |
| |
| /* |
| * Should only be called when the document's write lock is held. |
| */ |
| private class M2TReconcilingUnitOfWork<T> |
| implements IUnitOfWork<T, XtextResource> |
| { |
| private final IUnitOfWork<T, XtextResource> work; |
| private ISnapshot baseSnapshot; // snapshot before resource modifications |
| |
| public M2TReconcilingUnitOfWork(IUnitOfWork<T, XtextResource> work) |
| { |
| this.work = work; |
| } |
| |
| @Override |
| public T exec(XtextResource resource) throws Exception |
| { |
| if (resource == null) // input not set or already disposed |
| return work.exec(resource); |
| |
| hasTopLevelModification.set(true); |
| try |
| { |
| internalReconcile(resource); // ensure a fresh base snapshot |
| baseSnapshot = getReconciledSnapshot(); |
| T result; |
| try |
| { |
| // resolve all proxies before model modification |
| // (otherwise, proxy resolution might throw exceptions |
| // due to inconsistency between 'changed' model and |
| // 'old' proxy URIs) |
| EcoreUtil2.resolveAll(resource, CancelIndicator.NullImpl); |
| |
| composer2.beginRecording(resource); |
| result = work.exec(resource); |
| TextEdit edit = composer2.endRecording(); |
| if (edit != null) |
| { |
| DocumentChange change = new DocumentChange(edit); |
| change.setBase(baseSnapshot); |
| if (work instanceof IUndoableUnitOfWork) |
| change.setStyle(IDocumentChange.CREATE_UNDO); |
| else |
| change.setStyle(IDocumentChange.NONE); |
| |
| IDocumentChange undoChange = applyChange(change); |
| |
| if (work instanceof IUndoableUnitOfWork) |
| ((IUndoableUnitOfWork<T, XtextResource>)work).acceptUndoChange( |
| undoChange); |
| } |
| } |
| catch (Exception e) |
| { |
| // modification failed - restore state |
| Activator.log(Activator.createErrorStatus(e.getMessage(), |
| e)); |
| resource.reparse(reconciledSnapshot.getContents()); |
| throw e; |
| } |
| internalReconcile(resource); // reconcile resource with changed document |
| return result; |
| } |
| finally |
| { |
| hasTopLevelModification.remove(); |
| } |
| } |
| } |
| |
| private static class BooleanThreadLocal |
| extends ThreadLocal<Boolean> |
| { |
| @Override |
| protected Boolean initialValue() |
| { |
| return Boolean.FALSE; |
| } |
| } |
| |
| // Special processor for #internalReconcile |
| private static class InternalProcessor |
| implements Processor |
| { |
| private final XtextResource resource; |
| |
| public InternalProcessor(XtextResource resource) |
| { |
| this.resource = resource; |
| } |
| |
| @Override |
| public <T> T process(IUnitOfWork<T, XtextResource> transaction) |
| { |
| try |
| { |
| return transaction.exec(resource); |
| } |
| catch (RuntimeException e) |
| { |
| throw e; |
| } |
| catch (Exception e) |
| { |
| throw new WrappedException(e); |
| } |
| } |
| } |
| |
| // Initially copied from XtextDocumentReconcileStrategy#postParse |
| private class PostReconcileProcessor |
| implements IReconcilingListener |
| { |
| @Override |
| public void reconciled(XtextResource resource, |
| NonExpiringSnapshot snapshot, boolean forced, |
| IProgressMonitor monitor) throws Exception |
| { |
| CancelIndicator cancelIndicator = new CancelIndicator() |
| { |
| @Override |
| public boolean isCanceled() |
| { |
| return monitor.isCanceled(); |
| } |
| }; |
| try |
| { |
| EcoreUtil2.resolveLazyCrossReferences(resource, |
| cancelIndicator); |
| if (dirtyStateEditorSupport != null && !monitor.isCanceled()) |
| { |
| dirtyStateEditorSupport.announceDirtyState(resource); |
| } |
| } |
| catch (Error | RuntimeException e) |
| { |
| resource.getCache().clear(resource); |
| |
| if (!(e instanceof OperationCanceledError)) |
| throw e; |
| } |
| } |
| } |
| } |