/*
* Created on Jan 12, 2005
*/
package org.rubypeople.rdt.internal.ui.text.folding;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.text.Assert;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.source.Annotation;
import org.eclipse.jface.text.source.IAnnotationModel;
import org.eclipse.jface.text.source.projection.IProjectionListener;
import org.eclipse.jface.text.source.projection.IProjectionPosition;
import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel;
import org.eclipse.jface.text.source.projection.ProjectionViewer;
import org.eclipse.ui.texteditor.IDocumentProvider;
import org.eclipse.ui.texteditor.ITextEditor;
import org.rubypeople.rdt.core.ElementChangedEvent;
import org.rubypeople.rdt.core.IElementChangedListener;
import org.rubypeople.rdt.core.IMember;
import org.rubypeople.rdt.core.IParent;
import org.rubypeople.rdt.core.IRubyElement;
import org.rubypeople.rdt.core.IRubyElementDelta;
import org.rubypeople.rdt.core.IRubyScript;
import org.rubypeople.rdt.core.ISourceRange;
import org.rubypeople.rdt.core.ISourceReference;
import org.rubypeople.rdt.core.IType;
import org.rubypeople.rdt.core.RubyCore;
import org.rubypeople.rdt.core.RubyModelException;
import org.rubypeople.rdt.internal.corext.util.RDocUtil;
import org.rubypeople.rdt.internal.ui.RubyPlugin;
import org.rubypeople.rdt.internal.ui.rubyeditor.RubyAbstractEditor;
import org.rubypeople.rdt.internal.ui.rubyeditor.RubyEditor;
import org.rubypeople.rdt.ui.IWorkingCopyManager;
import org.rubypeople.rdt.ui.PreferenceConstants;
import org.rubypeople.rdt.ui.text.folding.IRubyFoldingStructureProvider;
import org.rubypeople.rdt.ui.text.folding.IRubyFoldingStructureProviderExtension;
/**
* @author cawilliams
*/
public class DefaultRubyFoldingStructureProvider implements IProjectionListener,
IRubyFoldingStructureProvider,
IRubyFoldingStructureProviderExtension
{
private ITextEditor fEditor;
private ProjectionViewer fViewer;
private IDocument fCachedDocument;
private ProjectionAnnotationModel fCachedModel;
private boolean fAllowCollapsing;
private IRubyElement fInput;
private IElementChangedListener fElementListener;
private boolean fCollapseInnerTypes;
private boolean fCollapseRubydoc;
private boolean fCollapseMethods;
/*
* (non-Javadoc)
* @see
* org.rubypeople.rdt.ui.text.folding.IRubyFoldingStructureProvider#install(org.eclipse.ui.texteditor.ITextEditor,
* org.eclipse.jface.text.source.projection.ProjectionViewer)
*/
public void install(ITextEditor editor, ProjectionViewer viewer)
{
if (editor instanceof RubyAbstractEditor)
{
fEditor = editor;
fViewer = viewer;
fViewer.addProjectionListener(this);
}
}
/*
* (non-Javadoc)
* @see org.rubypeople.rdt.ui.text.folding.IRubyFoldingStructureProvider#uninstall()
*/
public void uninstall()
{
if (isInstalled())
{
projectionDisabled();
fViewer.removeProjectionListener(this);
fViewer = null;
fEditor = null;
}
}
protected boolean isInstalled()
{
return fEditor != null;
}
/*
* (non-Javadoc)
* @see org.rubypeople.rdt.ui.text.folding.IRubyFoldingStructureProvider#initialize()
*/
public void initialize()
{
if (!isInstalled())
return;
initializePreferences();
try
{
IDocumentProvider provider = fEditor.getDocumentProvider();
fCachedDocument = provider.getDocument(fEditor.getEditorInput());
fAllowCollapsing = true;
if (fEditor instanceof RubyEditor)
{
IWorkingCopyManager manager = RubyPlugin.getDefault().getWorkingCopyManager();
fInput = manager.getWorkingCopy(fEditor.getEditorInput());
}
if (fInput != null)
{
ProjectionAnnotationModel model = (ProjectionAnnotationModel) fEditor
.getAdapter(ProjectionAnnotationModel.class);
if (model != null)
{
fCachedModel = model;
if (fInput instanceof IRubyScript)
{
IRubyScript unit = (IRubyScript) fInput;
synchronized (unit)
{
try
{
unit.reconcile();
}
catch (RubyModelException e)
{
}
}
}
Map<RubyProjectionAnnotation, Position> additions = computeAdditions((IParent) fInput);
/*
* Minimize the events being sent out - as this happens in the UI thread merge everything into one
* call.
*/
List removals = new LinkedList();
Iterator existing = model.getAnnotationIterator();
while (existing.hasNext())
removals.add(existing.next());
model.replaceAnnotations((Annotation[]) removals.toArray(new Annotation[removals.size()]),
additions);
}
}
}
finally
{
fCachedDocument = null;
fAllowCollapsing = false;
fCachedModel = null;
}
}
/**
* @param input
* @return
*/
private Map<RubyProjectionAnnotation, Position> computeAdditions(IParent parent)
{
Map<RubyProjectionAnnotation, Position> map = new HashMap<RubyProjectionAnnotation, Position>();
try
{
computeAdditions(parent.getChildren(), map);
}
catch (RubyModelException x)
{
RubyPlugin.log(x);
}
return map;
}
private void computeAdditions(IRubyElement[] elements, Map<RubyProjectionAnnotation, Position> map) throws RubyModelException
{
for (int i = 0; i < elements.length; i++)
{
IRubyElement element = elements[i];
computeAdditions(element, map);
if (element instanceof IParent)
{
IParent parent = (IParent) element;
computeAdditions(parent.getChildren(), map);
}
}
}
/**
* @param element
* @param map
*/
private void computeAdditions(IRubyElement element, Map<RubyProjectionAnnotation, Position> map)
{
boolean createProjection = false;
boolean collapse = false;
switch (element.getElementType())
{
case IRubyElement.TYPE:
collapse = fAllowCollapsing && fCollapseInnerTypes && isInnerType((IType) element);
createProjection = true;
break;
case IRubyElement.METHOD:
collapse = fAllowCollapsing && fCollapseMethods;
createProjection = true;
break;
case IRubyElement.BLOCK:
collapse = false;
createProjection = true;
break;
}
if (createProjection)
{
IRegion[] regions = computeProjectionRanges(element);
if (regions != null)
{
// comments
for (int i = 0; i < regions.length - 1; i++)
{
Position position = createProjectionPosition(regions[i]);
if (position != null)
map.put(new RubyProjectionAnnotation(element, fAllowCollapsing && fCollapseRubydoc, true),
position);
}
// code
Position position = createProjectionPosition(regions[regions.length - 1]);
if (position != null)
map.put(new RubyProjectionAnnotation(element, collapse, false), position);
}
}
}
private void initializePreferences()
{
IPreferenceStore store = RubyPlugin.getDefault().getPreferenceStore();
fCollapseInnerTypes = store.getBoolean(PreferenceConstants.EDITOR_FOLDING_INNERTYPES);
fCollapseRubydoc = store.getBoolean(PreferenceConstants.EDITOR_FOLDING_RDOC);
fCollapseMethods = store.getBoolean(PreferenceConstants.EDITOR_FOLDING_METHODS);
}
private boolean isInnerType(IType type)
{
IRubyElement parent = type.getParent();
if (parent != null)
{
int parentType = parent.getElementType();
return (parentType != IRubyElement.SCRIPT);
}
return false;
}
private IRegion[] computeProjectionRanges(IRubyElement element)
{
try
{
if (element instanceof ISourceReference)
{
ISourceReference reference = (ISourceReference) element;
ISourceRange range = reference.getSourceRange();
String contents = reference.getSource();
if (contents == null)
return null;
List<IRegion> regions = new ArrayList<IRegion>();
int shift = range.getOffset();
int start = shift;
IRegion region = null;
if (element instanceof IMember)
region = RDocUtil.getDocumentationRegion((IMember) element);
if (region != null)
regions.add(region);
regions.add(new Region(start, range.getOffset() + range.getLength() - start));
if (regions.size() > 0)
{
IRegion[] result = new IRegion[regions.size()];
regions.toArray(result);
return result;
}
}
}
catch (RubyModelException e)
{
}
return null;
}
private Position createProjectionPosition(IRegion region)
{
if (fCachedDocument == null)
return null;
try
{
int start = fCachedDocument.getLineOfOffset(region.getOffset());
int end = fCachedDocument.getLineOfOffset(region.getOffset() + region.getLength());
if (start != end)
{
int offset = fCachedDocument.getLineOffset(start);
int endOffset = -1;
if ((end + 1) == fCachedDocument.getNumberOfLines())
{
endOffset = fCachedDocument.getLength();
}
else
{
endOffset = fCachedDocument.getLineOffset(end + 1);
}
return new Position(offset, endOffset - offset);
}
}
catch (BadLocationException x)
{
}
return null;
}
/*
* (non-Javadoc)
* @see org.eclipse.jface.text.source.projection.IProjectionListener#projectionEnabled()
*/
public void projectionEnabled()
{
// http://home.ott.oti.com/teams/wswb/anon/out/vms/index.html
// projectionEnabled messages are not always paired with
// projectionDisabled
// i.e. multiple enabled messages may be sent out.
// we have to make sure that we disable first when getting an enable
// message.
projectionDisabled();
if (fEditor instanceof RubyAbstractEditor)
{
initialize();
fElementListener = new ElementChangedListener();
RubyCore.addElementChangedListener(fElementListener);
}
}
/*
* (non-Javadoc)
* @see org.eclipse.jface.text.source.projection.IProjectionListener#projectionDisabled()
*/
public void projectionDisabled()
{
fCachedDocument = null;
if (fElementListener != null)
{
RubyCore.removeElementChangedListener(fElementListener);
fElementListener = null;
}
}
protected void processDelta(IRubyElementDelta delta)
{
if (!isInstalled())
return;
if ((delta.getFlags() & (IRubyElementDelta.F_CONTENT | IRubyElementDelta.F_CHILDREN)) == 0)
return;
ProjectionAnnotationModel model = (ProjectionAnnotationModel) fEditor
.getAdapter(ProjectionAnnotationModel.class);
if (model == null)
return;
try
{
IDocumentProvider provider = fEditor.getDocumentProvider();
fCachedDocument = provider.getDocument(fEditor.getEditorInput());
fCachedModel = model;
fAllowCollapsing = false;
Map additions = new HashMap();
List deletions = new ArrayList();
List updates = new ArrayList();
Map updated = computeAdditions((IParent) fInput);
Map previous = createAnnotationMap(model);
Iterator e = updated.keySet().iterator();
while (e.hasNext())
{
RubyProjectionAnnotation newAnnotation = (RubyProjectionAnnotation) e.next();
IRubyElement element = newAnnotation.getElement();
Position newPosition = (Position) updated.get(newAnnotation);
List annotations = (List) previous.get(element);
if (annotations == null)
{
additions.put(newAnnotation, newPosition);
}
else
{
Iterator x = annotations.iterator();
boolean matched = false;
while (x.hasNext())
{
Tuple tuple = (Tuple) x.next();
RubyProjectionAnnotation existingAnnotation = tuple.annotation;
Position existingPosition = tuple.position;
if (newAnnotation.isComment() == existingAnnotation.isComment())
{
if (existingPosition != null && (!newPosition.equals(existingPosition)))
{
existingPosition.setOffset(newPosition.getOffset());
existingPosition.setLength(newPosition.getLength());
updates.add(existingAnnotation);
}
matched = true;
x.remove();
break;
}
}
if (!matched)
additions.put(newAnnotation, newPosition);
if (annotations.isEmpty())
previous.remove(element);
}
}
e = previous.values().iterator();
while (e.hasNext())
{
List list = (List) e.next();
int size = list.size();
for (int i = 0; i < size; i++)
deletions.add(((Tuple) list.get(i)).annotation);
}
match(deletions, additions, updates);
Annotation[] removals = new Annotation[deletions.size()];
deletions.toArray(removals);
Annotation[] changes = new Annotation[updates.size()];
updates.toArray(changes);
model.modifyAnnotations(removals, additions, changes);
}
finally
{
fCachedDocument = null;
fAllowCollapsing = true;
fCachedModel = null;
}
}
private Map createAnnotationMap(IAnnotationModel model)
{
Map map = new HashMap();
Iterator e = model.getAnnotationIterator();
while (e.hasNext())
{
Object annotation = e.next();
if (annotation instanceof RubyProjectionAnnotation)
{
RubyProjectionAnnotation ruby = (RubyProjectionAnnotation) annotation;
Position position = model.getPosition(ruby);
Assert.isNotNull(position);
List list = (List) map.get(ruby.getElement());
if (list == null)
{
list = new ArrayList(2);
map.put(ruby.getElement(), list);
}
list.add(new Tuple(ruby, position));
}
}
Comparator comparator = new Comparator()
{
public int compare(Object o1, Object o2)
{
return ((Tuple) o1).position.getOffset() - ((Tuple) o2).position.getOffset();
}
};
for (Iterator it = map.values().iterator(); it.hasNext();)
{
List list = (List) it.next();
Collections.sort(list, comparator);
}
return map;
}
/**
* Matches deleted annotations to changed or added ones. A deleted annotation/position tuple that has a matching
* addition / change is updated and marked as changed. The matching tuple is not added (for additions) or marked as
* deletion instead (for changes). The result is that more annotations are changed and fewer get deleted/re-added.
*/
private void match(List deletions, Map additions, List changes)
{
if (deletions.isEmpty() || (additions.isEmpty() && changes.isEmpty()))
return;
List newDeletions = new ArrayList();
List newChanges = new ArrayList();
Iterator deletionIterator = deletions.iterator();
while (deletionIterator.hasNext())
{
RubyProjectionAnnotation deleted = (RubyProjectionAnnotation) deletionIterator.next();
if (fCachedModel == null)
continue;
Position deletedPosition = fCachedModel.getPosition(deleted);
if (deletedPosition == null)
continue;
Tuple deletedTuple = new Tuple(deleted, deletedPosition);
Tuple match = findMatch(deletedTuple, changes, null);
boolean addToDeletions = true;
if (match == null)
{
match = findMatch(deletedTuple, additions.keySet(), additions);
addToDeletions = false;
}
if (match != null)
{
IRubyElement element = match.annotation.getElement();
deleted.setElement(element);
deletedPosition.setLength(match.position.getLength());
if (deletedPosition instanceof RubyElementPosition && element instanceof IMember)
{
RubyElementPosition jep = (RubyElementPosition) deletedPosition;
jep.setMember((IMember) element);
}
deletionIterator.remove();
newChanges.add(deleted);
if (addToDeletions)
newDeletions.add(match.annotation);
}
}
deletions.addAll(newDeletions);
changes.addAll(newChanges);
}
/**
* Finds a match for <code>tuple</code> in a collection of annotations. The positions for the
* <code>JavaProjectionAnnotation</code> instances in <code>annotations</code> can be found in the passed
* <code>positionMap</code> or <code>fCachedModel</code> if <code>positionMap</code> is <code>null</code>.
* <p>
* A tuple is said to match another if their annotations have the same comment flag and their position offsets are
* equal.
* </p>
* <p>
* If a match is found, the annotation gets removed from <code>annotations</code>.
* </p>
*
* @param tuple
* the tuple for which we want to find a match
* @param annotations
* collection of <code>JavaProjectionAnnotation</code>
* @param positionMap
* a <code>Map<Annotation, Position></code> or <code>null</code>
* @return a matching tuple or <code>null</code> for no match
*/
private Tuple findMatch(Tuple tuple, Collection annotations, Map positionMap)
{
Iterator it = annotations.iterator();
while (it.hasNext())
{
RubyProjectionAnnotation annotation = (RubyProjectionAnnotation) it.next();
if (tuple.annotation.isComment() == annotation.isComment())
{
Position position = positionMap == null ? fCachedModel.getPosition(annotation) : (Position) positionMap
.get(annotation);
if (position == null)
continue;
if (tuple.position.getOffset() == position.getOffset())
{
it.remove();
return new Tuple(annotation, position);
}
}
}
return null;
}
private static final class Tuple
{
RubyProjectionAnnotation annotation;
Position position;
Tuple(RubyProjectionAnnotation annotation, Position position)
{
this.annotation = annotation;
this.position = position;
}
}
private class ElementChangedListener implements IElementChangedListener
{
/*
* @see org.eclipse.jdt.core.IElementChangedListener#elementChanged(org.eclipse.jdt.core.ElementChangedEvent)
*/
public void elementChanged(ElementChangedEvent e)
{
IRubyElementDelta delta = findElement(fInput, e.getDelta());
if (delta != null)
{
if (delta.getRubyScriptAST() == null)
return;
processDelta(delta);
}
}
private IRubyElementDelta findElement(IRubyElement target, IRubyElementDelta delta)
{
if (delta == null || target == null)
return null;
IRubyElement element = delta.getElement();
if (element.getElementType() > IRubyElement.SCRIPT)
return null;
if (target.equals(element))
return delta;
IRubyElementDelta[] children = delta.getAffectedChildren();
for (int i = 0; i < children.length; i++)
{
IRubyElementDelta d = findElement(target, children[i]);
if (d != null)
return d;
}
return null;
}
}
private static class RubyProjectionAnnotation extends ProjectionAnnotation
{
private IRubyElement fRubyElement;
private boolean fIsComment;
public RubyProjectionAnnotation(IRubyElement element, boolean isCollapsed, boolean isComment)
{
super(isCollapsed);
fRubyElement = element;
fIsComment = isComment;
}
public IRubyElement getElement()
{
return fRubyElement;
}
public void setElement(IRubyElement element)
{
fRubyElement = element;
}
public boolean isComment()
{
return fIsComment;
}
public void setIsComment(boolean isComment)
{
fIsComment = isComment;
}
}
/**
* Projection position that will return two foldable regions: one folding away the lines before the one containing
* the simple name of the ruby element, one folding away any lines after the caption.
*
* @since 0.7.0
*/
private static final class RubyElementPosition extends Position implements IProjectionPosition
{
private IMember fMember;
public RubyElementPosition(int offset, int length, IMember member)
{
super(offset, length);
Assert.isNotNull(member);
fMember = member;
}
public void setMember(IMember member)
{
Assert.isNotNull(member);
fMember = member;
}
/*
* @see
* org.eclipse.jface.text.source.projection.IProjectionPosition#computeFoldingRegions(org.eclipse.jface.text
* .IDocument)
*/
public IRegion[] computeProjectionRegions(IDocument document) throws BadLocationException
{
int nameStart = offset;
try
{
/*
* The member's name range may not be correct. However, reconciling would trigger another element delta
* which would lead to reentrant situations. Therefore, we optimistically assume that the name range is
* correct, but double check the received lines below.
*/
ISourceRange nameRange = fMember.getNameRange();
if (nameRange != null)
nameStart = nameRange.getOffset();
}
catch (RubyModelException e)
{
// ignore and use default
}
int firstLine = document.getLineOfOffset(offset);
int captionLine = document.getLineOfOffset(nameStart);
int lastLine = document.getLineOfOffset(offset + length);
/*
* see comment above - adjust the caption line to be inside the entire folded region, and rely on later
* element deltas to correct the name range.
*/
if (captionLine < firstLine)
captionLine = firstLine;
if (captionLine > lastLine)
captionLine = lastLine;
IRegion preRegion;
if (firstLine < captionLine)
{
int preOffset = document.getLineOffset(firstLine);
IRegion preEndLineInfo = document.getLineInformation(captionLine);
int preEnd = preEndLineInfo.getOffset();
preRegion = new Region(preOffset, preEnd - preOffset);
}
else
{
preRegion = null;
}
if (captionLine < lastLine)
{
int postOffset = document.getLineOffset(captionLine + 1);
IRegion postRegion = new Region(postOffset, offset + length - postOffset);
if (preRegion == null)
return new IRegion[] { postRegion };
return new IRegion[] { preRegion, postRegion };
}
if (preRegion != null)
return new IRegion[] { preRegion };
return null;
}
/*
* @see
* org.eclipse.jface.text.source.projection.IProjectionPosition#computeCaptionOffset(org.eclipse.jface.text.
* IDocument)
*/
public int computeCaptionOffset(IDocument document) throws BadLocationException
{
int nameStart = offset;
try
{
// need a reconcile here?
ISourceRange nameRange = fMember.getNameRange();
if (nameRange != null)
nameStart = nameRange.getOffset();
}
catch (RubyModelException e)
{
// ignore and use default
}
return nameStart - offset;
}
}
/* filters */
/**
* Filter for annotations.
*
* @since 0.9.0
*/
private static interface Filter
{
boolean match(RubyProjectionAnnotation annotation);
}
private static final class RubyElementSetFilter implements Filter
{
private final Set<IRubyElement> fSet;
private final boolean fMatchCollapsed;
private RubyElementSetFilter(Set<IRubyElement> set, boolean matchCollapsed)
{
fSet = set;
fMatchCollapsed = matchCollapsed;
}
public boolean match(RubyProjectionAnnotation annotation)
{
boolean stateMatch = fMatchCollapsed == annotation.isCollapsed();
if (stateMatch && !annotation.isComment() && !annotation.isMarkedDeleted())
{
IRubyElement element = annotation.getElement();
if (fSet.contains(element))
{
return true;
}
}
return false;
}
}
/**
* Member filter, matches nested members (but not top-level types).
*
* @since 0.9.0
*/
private final Filter fMemberFilter = new Filter()
{
public boolean match(RubyProjectionAnnotation annotation)
{
if (!annotation.isCollapsed() && !annotation.isComment() && !annotation.isMarkedDeleted())
{
IRubyElement element = annotation.getElement();
if (element instanceof IMember)
{
if (element.getElementType() != IRubyElement.TYPE || ((IMember) element).getDeclaringType() != null)
{
return true;
}
}
}
return false;
}
};
/**
* Comment filter, matches comments.
*
* @since 0.9.0
*/
private final Filter fCommentFilter = new Filter()
{
public boolean match(RubyProjectionAnnotation annotation)
{
if (!annotation.isCollapsed() && annotation.isComment() && !annotation.isMarkedDeleted())
{
return true;
}
return false;
}
};
public void collapseMembers()
{
modifyFiltered(fMemberFilter, false);
}
public void collapseComments()
{
modifyFiltered(fCommentFilter, false);
}
public void collapseElements(IRubyElement[] elements)
{
Set<IRubyElement> set = new HashSet<IRubyElement>(Arrays.asList(elements));
modifyFiltered(new RubyElementSetFilter(set, false), false);
}
public void expandElements(IRubyElement[] elements)
{
Set<IRubyElement> set = new HashSet<IRubyElement>(Arrays.asList(elements));
modifyFiltered(new RubyElementSetFilter(set, true), true);
}
/**
* Collapses all annotations matched by the passed filter.
*
* @param filter
* the filter to use to select which annotations to collapse
* @param expand
* <code>true</code> to expand the matched annotations, <code>false</code> to collapse them
* @since 0.9.0
*/
private void modifyFiltered(Filter filter, boolean expand)
{
if (!isInstalled())
return;
ProjectionAnnotationModel model = (ProjectionAnnotationModel) fEditor
.getAdapter(ProjectionAnnotationModel.class);
if (model == null)
return;
List modified = new ArrayList();
Iterator iter = model.getAnnotationIterator();
while (iter.hasNext())
{
Object annotation = iter.next();
if (annotation instanceof RubyProjectionAnnotation)
{
RubyProjectionAnnotation ruby = (RubyProjectionAnnotation) annotation;
if (filter.match(ruby))
{
if (expand)
ruby.markExpanded();
else
ruby.markCollapsed();
modified.add(ruby);
}
}
}
model.modifyAnnotations(null, null, (Annotation[]) modified.toArray(new Annotation[modified.size()]));
}
}