/******************************************************************************* * Copyright (c) 2000, 2015 IBM Corporation and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * 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.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 (IDocument doc : result.keySet()) { TextEdit edit= result.get(doc); 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; } }