blob: dac5e74998a93a78f66d50c31ea6c4697f0ad0f8 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2000, 2015 IBM Corporation 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
*
* Contributors:
* IBM Corporation - initial API and implementation
*******************************************************************************/
package org.eclipse.jface.text.link;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.eclipse.core.runtime.Assert;
import org.eclipse.text.edits.MalformedTreeException;
import org.eclipse.text.edits.TextEdit;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.BadPositionCategoryException;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentExtension;
import org.eclipse.jface.text.IDocumentExtension.IReplace;
import org.eclipse.jface.text.IDocumentListener;
import org.eclipse.jface.text.IPositionUpdater;
import org.eclipse.jface.text.Position;
/**
* The model for linked mode, umbrellas several
* {@link LinkedPositionGroup}s. Once installed, the model
* propagates any changes to a position to all its siblings in the same position
* group.
* <p>
* Setting up a model consists of first adding
* <code>LinkedPositionGroup</code>s to it, and then installing the
* model by either calling {@link #forceInstall()} or
* {@link #tryInstall()}. After installing the model, it becomes
* <em>sealed</em> and no more groups may be added.
* </p>
* <p>
* If a document change occurs that would modify more than one position
* group or that would invalidate the disjointness requirement of the positions,
* the model is torn down and all positions are deleted. The same happens
* upon calling {@link #exit(int)}.
* </p>
* <h4>Nesting</h4>
* <p>
* A <code>LinkedModeModel</code> may be nested into another model. This
* happens when installing a model the positions of which all fit into a
* single position in a parent model that has previously been installed on
* the same document(s).
* </p>
* <p>
* Clients may instantiate instances of this class.
* </p>
*
* @since 3.0
* @noextend This class is not intended to be subclassed by clients.
*/
public class LinkedModeModel {
/**
* Checks whether there is already a model installed on <code>document</code>.
*
* @param document the <code>IDocument</code> of interest
* @return <code>true</code> if there is an existing model, <code>false</code>
* otherwise
*/
public static boolean hasInstalledModel(IDocument document) {
// if there is a manager, there also is a model
return LinkedModeManager.hasManager(document);
}
/**
* Checks whether there is already a linked mode model installed on any of
* the <code>documents</code>.
*
* @param documents the <code>IDocument</code>s of interest
* @return <code>true</code> if there is an existing model, <code>false</code>
* otherwise
*/
public static boolean hasInstalledModel(IDocument[] documents) {
// if there is a manager, there also is a model
return LinkedModeManager.hasManager(documents);
}
/**
* Cancels any linked mode model on the specified document. If there is no
* model, nothing happens.
*
* @param document the document whose <code>LinkedModeModel</code> should
* be canceled
*/
public static void closeAllModels(IDocument document) {
LinkedModeManager.cancelManager(document);
}
/**
* Returns the model currently active on <code>document</code> at
* <code>offset</code>, or <code>null</code> if there is none.
*
* @param document the document for which the caller asks for a
* model
* @param offset the offset into <code>document</code>, as there may be
* several models on a document
* @return the model currently active on <code>document</code>, or
* <code>null</code>
*/
public static LinkedModeModel getModel(IDocument document, int offset) {
if (!hasInstalledModel(document))
return null;
LinkedModeManager mgr= LinkedModeManager.getLinkedManager(new IDocument[] {document}, false);
if (mgr != null)
return mgr.getTopEnvironment();
return null;
}
/**
* Encapsulates the edition triggered by a change to a linking position. Can
* be applied to a document as a whole.
*/
private class Replace implements IReplace {
/** The edition to apply on a document. */
private TextEdit fEdit;
/**
* Creates a new instance.
*
* @param edit the edition to apply to a document.
*/
public Replace(TextEdit edit) {
fEdit= edit;
}
@Override
public void perform(IDocument document, IDocumentListener owner) throws RuntimeException, MalformedTreeException {
document.removeDocumentListener(owner);
fIsChanging= true;
try {
fEdit.apply(document, TextEdit.UPDATE_REGIONS | TextEdit.CREATE_UNDO);
} catch (BadLocationException e) {
/* XXX: perform should really throw a BadLocationException
* see https://bugs.eclipse.org/bugs/show_bug.cgi?id=52950
*/
throw new RuntimeException(e);
} finally {
document.addDocumentListener(owner);
fIsChanging= false;
}
}
}
/**
* The document listener triggering the linked updating of positions
* managed by this model.
*/
private class DocumentListener implements IDocumentListener {
private boolean fExit= false;
/**
* Checks whether <code>event</code> occurs within any of the positions
* managed by this model. If not, the linked mode is left.
*
* @param event {@inheritDoc}
*/
@Override
public void documentAboutToBeChanged(DocumentEvent event) {
// don't react on changes executed by the parent model
if (fParentEnvironment != null && fParentEnvironment.isChanging())
return;
for (LinkedPositionGroup group : fGroups) {
if (!group.isLegalEvent(event)) {
fExit= true;
return;
}
}
}
/**
* Propagates a change to a linked position to all its sibling positions.
*
* @param event {@inheritDoc}
*/
@Override
public void documentChanged(DocumentEvent event) {
if (fExit) {
LinkedModeModel.this.exit(ILinkedModeListener.EXTERNAL_MODIFICATION);
return;
}
fExit= false;
// don't react on changes executed by the parent model
if (fParentEnvironment != null && fParentEnvironment.isChanging())
return;
// collect all results
Map<IDocument, TextEdit> result= null;
for (LinkedPositionGroup group : fGroups) {
Map<IDocument, TextEdit> map= group.handleEvent(event);
if (result != null && map != null) {
// exit if more than one position was changed
LinkedModeModel.this.exit(ILinkedModeListener.EXTERNAL_MODIFICATION);
return;
}
if (map != null)
result= map;
}
if (result != null) {
// edit all documents
for (Entry<IDocument, TextEdit> entry : result.entrySet()) {
IDocument doc = entry.getKey();
TextEdit edit= entry.getValue();
Replace replace= new Replace(edit);
// apply the edition, either as post notification replace
// on the calling document or directly on any other
// document
if (doc == event.getDocument()) {
if (doc instanceof IDocumentExtension) {
((IDocumentExtension) doc).registerPostNotificationReplace(this, replace);
} else {
// ignore - there is no way we can log from JFace text...
}
} else {
replace.perform(doc, this);
}
}
}
}
}
/** The set of linked position groups. */
private final List<LinkedPositionGroup> fGroups= new ArrayList<>();
/** The set of documents spanned by this group. */
private final Set<IDocument> fDocuments= new HashSet<>();
/** The position updater for linked positions. */
private final IPositionUpdater fUpdater= new InclusivePositionUpdater(getCategory());
/** The document listener on the documents affected by this model. */
private final DocumentListener fDocumentListener= new DocumentListener();
/** The parent model for a hierarchical set up, or <code>null</code>. */
private LinkedModeModel fParentEnvironment;
/**
* The position in <code>fParentEnvironment</code> that includes all
* positions in this object, or <code>null</code> if there is no parent
* model.
*/
private LinkedPosition fParentPosition= null;
/**
* A model is sealed once it has children - no more positions can be
* added.
*/
private boolean fIsSealed= false;
/** <code>true</code> when this model is changing documents. */
private boolean fIsChanging= false;
/** The linked listeners. */
private final List<ILinkedModeListener> fListeners= new ArrayList<>();
/** Flag telling whether we have exited: */
private boolean fIsActive= true;
/**
* The sequence of document positions as we are going to iterate through
* them.
*/
private List<LinkedPosition> fPositionSequence= new ArrayList<>();
/**
* Whether we are in the process of editing documents (set by <code>Replace</code>,
* read by <code>DocumentListener</code>.
*
* @return <code>true</code> if we are in the process of editing a
* document, <code>false</code> otherwise
*/
private boolean isChanging() {
return fIsChanging || fParentEnvironment != null && fParentEnvironment.isChanging();
}
/**
* Throws a <code>BadLocationException</code> if <code>group</code>
* conflicts with this model's groups.
*
* @param group the group being checked
* @throws BadLocationException if <code>group</code> conflicts with this
* model's groups
*/
private void enforceDisjoint(LinkedPositionGroup group) throws BadLocationException {
for (LinkedPositionGroup g : fGroups) {
g.enforceDisjoint(group);
}
}
/**
* Causes this model to exit. Called either if an illegal document change
* is detected, or by the UI.
*
* @param flags the exit flags as defined in {@link ILinkedModeListener}
*/
public void exit(int flags) {
if (!fIsActive)
return;
fIsActive= false;
for (IDocument doc : fDocuments) {
try {
doc.removePositionCategory(getCategory());
} catch (BadPositionCategoryException e) {
// won't happen
Assert.isTrue(false);
}
doc.removePositionUpdater(fUpdater);
doc.removeDocumentListener(fDocumentListener);
}
fDocuments.clear();
fGroups.clear();
List<ILinkedModeListener> listeners= new ArrayList<>(fListeners);
fListeners.clear();
for (ILinkedModeListener listener : listeners) {
listener.left(this, flags);
}
if (fParentEnvironment != null)
fParentEnvironment.resume(flags);
}
/**
* Causes this model to stop forwarding updates. The positions are not
* unregistered however, which will only happen when <code>exit</code>
* is called, or after the next document change.
*
* @param flags the exit flags as defined in {@link ILinkedModeListener}
* @since 3.1
*/
public void stopForwarding(int flags) {
fDocumentListener.fExit= true;
}
/**
* Puts <code>document</code> into the set of managed documents. This
* involves registering the document listener and adding our position
* category.
*
* @param document the new document
*/
private void manageDocument(IDocument document) {
if (!fDocuments.contains(document)) {
fDocuments.add(document);
document.addPositionCategory(getCategory());
document.addPositionUpdater(fUpdater);
document.addDocumentListener(fDocumentListener);
}
}
/**
* Returns the position category used by this model.
*
* @return the position category used by this model
*/
private String getCategory() {
return toString();
}
/**
* Adds a position group to this <code>LinkedModeModel</code>. This
* method may not be called if the model has been installed. Also, if
* a UI has been set up for this model, it may not pick up groups
* added afterwards.
* <p>
* If the positions in <code>group</code> conflict with any other group in
* this model, a <code>BadLocationException</code> is thrown. Also,
* if this model is nested inside another one, all positions in all
* groups of the child model have to reside within a single position in the
* parent model, otherwise a <code>BadLocationException</code> is thrown.
* </p>
* <p>
* If <code>group</code> already exists, nothing happens.
* </p>
*
* @param group the group to be added to this model
* @throws BadLocationException if the group conflicts with the other groups
* in this model or violates the nesting requirements.
* @throws IllegalStateException if the method is called when the
* model is already sealed
*/
public void addGroup(LinkedPositionGroup group) throws BadLocationException {
if (group == null)
throw new IllegalArgumentException("group may not be null"); //$NON-NLS-1$
if (fIsSealed)
throw new IllegalStateException("model is already installed"); //$NON-NLS-1$
if (fGroups.contains(group))
// nothing happens
return;
enforceDisjoint(group);
group.seal();
fGroups.add(group);
}
/**
* Creates a new model.
* @since 3.1
*/
public LinkedModeModel() {
}
/**
* Installs this model, which includes registering as document
* listener on all involved documents and storing global information about
* this model. Any conflicting model already present will be
* closed.
* <p>
* If an exception is thrown, the installation failed and
* the model is unusable.
* </p>
*
* @throws BadLocationException if some of the positions of this model
* were not valid positions on their respective documents
*/
public void forceInstall() throws BadLocationException {
if (!install(true))
Assert.isTrue(false);
}
/**
* Installs this model, which includes registering as document
* listener on all involved documents and storing global information about
* this model. If there is another model installed on the
* document(s) targeted by the receiver that conflicts with it, installation
* may fail.
* <p>
* The return value states whether installation was
* successful; if not, the model is not installed and will not work.
* </p>
*
* @return <code>true</code> if installation was successful,
* <code>false</code> otherwise
* @throws BadLocationException if some of the positions of this model
* were not valid positions on their respective documents
*/
public boolean tryInstall() throws BadLocationException {
return install(false);
}
/**
* Installs this model, which includes registering as document
* listener on all involved documents and storing global information about
* this model. The return value states whether installation was
* successful; if not, the model is not installed and will not work.
* The return value can only then become <code>false</code> if
* <code>force</code> was set to <code>false</code> as well.
*
* @param force if <code>true</code>, any other model that cannot
* coexist with this one is canceled; if <code>false</code>,
* install will fail when conflicts occur and return false
* @return <code>true</code> if installation was successful,
* <code>false</code> otherwise
* @throws BadLocationException if some of the positions of this model
* were not valid positions on their respective documents
*/
private boolean install(boolean force) throws BadLocationException {
if (fIsSealed)
throw new IllegalStateException("model is already installed"); //$NON-NLS-1$
enforceNotEmpty();
IDocument[] documents= getDocuments();
LinkedModeManager manager= LinkedModeManager.getLinkedManager(documents, force);
// if we force creation, we require a valid manager
Assert.isTrue(!(force && manager == null));
if (manager == null)
return false;
if (!manager.nestEnvironment(this, force))
if (force)
Assert.isTrue(false);
else
return false;
// we set up successfully. After this point, exit has to be called to
// remove registered listeners...
fIsSealed= true;
if (fParentEnvironment != null)
fParentEnvironment.suspend();
// register positions
try {
for (LinkedPositionGroup group : fGroups) {
group.register(this);
}
return true;
} catch (BadLocationException e){
// if we fail to add, make sure to release all listeners again
exit(ILinkedModeListener.NONE);
throw e;
}
}
/**
* Asserts that there is at least one linked position in this linked mode
* model, throws an IllegalStateException otherwise.
*/
private void enforceNotEmpty() {
boolean hasPosition= false;
for (LinkedPositionGroup linkedPositionGroup : fGroups)
if (!linkedPositionGroup.isEmpty()) {
hasPosition= true;
break;
}
if (!hasPosition)
throw new IllegalStateException("must specify at least one linked position"); //$NON-NLS-1$
}
/**
* Collects all the documents that contained positions are set upon.
* @return the set of documents affected by this model
*/
private IDocument[] getDocuments() {
Set<IDocument> docs= new HashSet<>();
for (LinkedPositionGroup group : fGroups) {
docs.addAll(Arrays.asList(group.getDocuments()));
}
return docs.toArray(new IDocument[docs.size()]);
}
/**
* Returns whether the receiver can be nested into the given <code>parent</code>
* model. If yes, the parent model and its position that the receiver
* fits in are remembered.
*
* @param parent the parent model candidate
* @return <code>true</code> if the receiver can be nested into <code>parent</code>, <code>false</code> otherwise
*/
boolean canNestInto(LinkedModeModel parent) {
for (LinkedPositionGroup group : fGroups) {
if (!enforceNestability(group, parent)) {
fParentPosition= null;
return false;
}
}
Assert.isNotNull(fParentPosition);
fParentEnvironment= parent;
return true;
}
/**
* Called by nested models when a group is added to them. All
* positions in all groups of a nested model have to fit inside a
* single position in the parent model.
*
* @param group the group of the nested model to be adopted.
* @param model the model to check against
* @return <code>false</code> if it failed to enforce nestability
*/
private boolean enforceNestability(LinkedPositionGroup group, LinkedModeModel model) {
Assert.isNotNull(model);
Assert.isNotNull(group);
try {
for (LinkedPositionGroup pg : model.fGroups) {
LinkedPosition pos;
pos= pg.adopt(group);
if (pos != null && fParentPosition != null && fParentPosition != pos)
return false; // group does not fit into one parent position, which is illegal
else if (fParentPosition == null && pos != null)
fParentPosition= pos;
}
} catch (BadLocationException e) {
return false;
}
// group must fit into exactly one of the parent's positions
return fParentPosition != null;
}
/**
* Returns whether this model is nested.
*
* <p>
* This method is part of the private protocol between
* <code>LinkedModeUI</code> and <code>LinkedModeModel</code>.
* </p>
*
* @return <code>true</code> if this model is nested,
* <code>false</code> otherwise
*/
public boolean isNested() {
return fParentEnvironment != null;
}
/**
* Returns the positions in this model that have a tab stop, in the
* order they were added.
*
* <p>
* This method is part of the private protocol between
* <code>LinkedModeUI</code> and <code>LinkedModeModel</code>.
* </p>
*
* @return the positions in this model that have a tab stop, in the
* order they were added
*/
public List<LinkedPosition> getTabStopSequence() {
return fPositionSequence;
}
/**
* Adds <code>listener</code> to the set of listeners that are informed
* upon state changes.
*
* @param listener the new listener
*/
public void addLinkingListener(ILinkedModeListener listener) {
Assert.isNotNull(listener);
if (!fListeners.contains(listener))
fListeners.add(listener);
}
/**
* Removes <code>listener</code> from the set of listeners that are
* informed upon state changes.
*
* @param listener the new listener
*/
public void removeLinkingListener(ILinkedModeListener listener) {
fListeners.remove(listener);
}
/**
* Finds the position in this model that is closest after
* <code>toFind</code>. <code>toFind</code> needs not be a position in
* this model and serves merely as an offset.
*
* <p>
* This method part of the private protocol between
* <code>LinkedModeUI</code> and <code>LinkedModeModel</code>.
* </p>
*
* @param toFind the position to search from
* @return the closest position in the same document as <code>toFind</code>
* after the offset of <code>toFind</code>, or <code>null</code>
*/
public LinkedPosition findPosition(LinkedPosition toFind) {
LinkedPosition position= null;
for (LinkedPositionGroup group : fGroups) {
position= group.getPosition(toFind);
if (position != null)
break;
}
return position;
}
/**
* Registers a <code>LinkedPosition</code> with this model. Called
* by <code>PositionGroup</code>.
*
* @param position the position to register
* @throws BadLocationException if the position cannot be added to its
* document
*/
void register(LinkedPosition position) throws BadLocationException {
Assert.isNotNull(position);
IDocument document= position.getDocument();
manageDocument(document);
try {
document.addPosition(getCategory(), position);
} catch (BadPositionCategoryException e) {
// won't happen as the category has been added by manageDocument()
Assert.isTrue(false);
}
int seqNr= position.getSequenceNumber();
if (seqNr != LinkedPositionGroup.NO_STOP) {
fPositionSequence.add(position);
}
}
/**
* Suspends this model.
*/
private void suspend() {
List<ILinkedModeListener> l= new ArrayList<>(fListeners);
for (ILinkedModeListener listener : l) {
listener.suspend(this);
}
}
/**
* Resumes this model. <code>flags</code> can be <code>NONE</code>
* or <code>SELECT</code>.
*
* @param flags <code>NONE</code> or <code>SELECT</code>
*/
private void resume(int flags) {
List<ILinkedModeListener> l= new ArrayList<>(fListeners);
for (ILinkedModeListener listener : l) {
listener.resume(this, flags);
}
}
/**
* Returns whether an offset is contained by any position in this
* model.
*
* @param offset the offset to check
* @return <code>true</code> if <code>offset</code> is included by any
* position (see {@link LinkedPosition#includes(int)}) in this
* model, <code>false</code> otherwise
*/
public boolean anyPositionContains(int offset) {
for (LinkedPositionGroup group : fGroups) {
if (group.contains(offset))
// take the first hit - exclusion is guaranteed by enforcing
// disjointness when adding positions
return true;
}
return false;
}
/**
* Returns the linked position group that contains <code>position</code>,
* or <code>null</code> if <code>position</code> is not contained in any
* group within this model. Group containment is tested by calling
* <code>group.contains(position)</code> for every <code>group</code> in
* this model.
*
* <p>
* This method part of the private protocol between
* <code>LinkedModeUI</code> and <code>LinkedModeModel</code>.
* </p>
*
* @param position the position the group of which is requested
* @return the first group in this model for which
* <code>group.contains(position)</code> returns <code>true</code>,
* or <code>null</code> if no group contains <code>position</code>
*/
public LinkedPositionGroup getGroupForPosition(Position position) {
for (LinkedPositionGroup group : fGroups) {
if (group.contains(position))
return group;
}
return null;
}
}