/*******************************************************************************
* Copyright (C) 2015 Thomas Wolf <thomas.wolf@paranor.ch>.
*
* 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
*******************************************************************************/
package org.eclipse.egit.ui.internal.dialogs;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.eclipse.core.runtime.Assert;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.resource.JFaceColors;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.TextAttribute;
import org.eclipse.jface.text.hyperlink.IHyperlink;
import org.eclipse.jface.text.hyperlink.IHyperlinkDetector;
import org.eclipse.jface.text.hyperlink.URLHyperlinkDetector;
import org.eclipse.jface.text.rules.IToken;
import org.eclipse.jface.text.rules.ITokenScanner;
import org.eclipse.jface.text.rules.Token;
import org.eclipse.jface.text.source.ISourceViewer;
import org.eclipse.jface.text.source.SourceViewerConfiguration;
import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.swt.graphics.Color;
import org.eclipse.ui.editors.text.EditorsUI;
import org.eclipse.ui.texteditor.AbstractTextEditor;
/**
* A simple {@link ITokenScanner} that recognizes hyperlinks using
* {@link IHyperlinkDetector}s.
*/
public class HyperlinkTokenScanner implements ITokenScanner {
private static final String URL_HYPERLINK_DETECTOR_KEY = "org.eclipse.ui.internal.editors.text.URLHyperlinkDetector"; //$NON-NLS-1$
private int tokenStart;
private int lastLineStart;
private IToken hyperlinkToken;
private Set<IHyperlinkDetector> hyperlinkDetectors;
private final ISourceViewer viewer;
private final SourceViewerConfiguration configuration;
/**
* The preference store to use to look up hyperlinking-related preferences.
*/
private final IPreferenceStore preferenceStore;
/**
* Caches all hyperlinks on a line to avoid calling the hyperlink detectors
* too often.
*/
private final List<IHyperlink> hyperlinksOnLine = new ArrayList<>();
/** The current offset in the document. */
protected int currentOffset;
/** The end of the range to tokenize. */
protected int endOfRange;
/** The {@link IDocument} the current scan operates on. */
protected IDocument document;
/** The {@link IToken} to use for default content. */
protected final IToken defaultToken;
/**
* Creates a new instance that uses the given hyperlink detector and viewer.
*
* @param configuration
* the {@link SourceViewerConfiguration}s to get the
* {@link IHyperlinkDetector}s from
* @param viewer
* the {@link ISourceViewer} to operate in
*/
public HyperlinkTokenScanner(SourceViewerConfiguration configuration,
ISourceViewer viewer) {
this(configuration, viewer, null);
}
/**
* Creates a new instance that uses the given hyperlink detector and viewer.
*
* @param configuration
* the {@link SourceViewerConfiguration}s to get the
* {@link IHyperlinkDetector}s from
* @param viewer
* the {@link ISourceViewer} to operate in
* @param defaultAttribute
* the {@link TextAttribute} to use for the default token; may be
* {@code null} to use the default style of the viewer
*/
public HyperlinkTokenScanner(SourceViewerConfiguration configuration,
ISourceViewer viewer, @Nullable TextAttribute defaultAttribute) {
this(configuration, viewer, null, defaultAttribute);
}
/**
* Creates a new instance that uses the given hyperlink detector and viewer.
*
* @param configuration
* the {@link SourceViewerConfiguration}s to get the
* {@link IHyperlinkDetector}s from
* @param viewer
* the {@link ISourceViewer} to operate in
* @param preferenceStore
* to use to look up preferences related to hyperlinking
* @param defaultAttribute
* the {@link TextAttribute} to use for the default token; may be
* {@code null} to use the default style of the viewer
*/
protected HyperlinkTokenScanner(SourceViewerConfiguration configuration,
ISourceViewer viewer, @Nullable IPreferenceStore preferenceStore,
@Nullable TextAttribute defaultAttribute) {
this.viewer = viewer;
this.defaultToken = new Token(defaultAttribute);
this.configuration = configuration;
this.preferenceStore = preferenceStore == null
? EditorsUI.getPreferenceStore() : preferenceStore;
}
@Override
public void setRange(IDocument document, int offset, int length) {
Assert.isNotNull(document);
setRangeAndColor(document, offset, length, JFaceColors
.getHyperlinkText(viewer.getTextWidget().getDisplay()));
}
@Override
public IToken nextToken() {
tokenStart = currentOffset;
if (currentOffset >= endOfRange) {
hyperlinksOnLine.clear();
return Token.EOF;
}
if (hyperlinkDetectors != null && !hyperlinkDetectors.isEmpty()) {
try {
IRegion currentLine = document
.getLineInformationOfOffset(currentOffset);
if (currentLine.getOffset() != lastLineStart) {
// Compute all hyperlinks in the line
hyperlinksOnLine.clear();
Iterator<IHyperlinkDetector> detectors = hyperlinkDetectors
.iterator();
while (detectors.hasNext()) {
IHyperlinkDetector hyperlinkDetector = detectors.next();
// The NoMaskHyperlinkDetectors can be skipped; if there
// are any, they're only duplicates of others to ensure
// hyperlinks open also if no modifier key is active.
if (hyperlinkDetector instanceof HyperlinkSourceViewer.NoMaskHyperlinkDetector) {
continue;
}
IHyperlink[] newLinks = null;
try {
newLinks = hyperlinkDetector.detectHyperlinks(
viewer, currentLine, false);
} catch (RuntimeException e) {
// Do *not* log: we have no way of identifying the
// broken hyperlink detector to ignore it in the
// future. Since we re-get the contributed detectors
// frequently, we'll get new instances of
// HyperlinkDetectorDelegate, and that doesn't give
// access to the extension id. So even if we remove
// the detector here, we may acquire a new broken
// instance again and then log over and over again,
// which is hyper-bothersome if the error log is
// open and set to activate on new errors. And
// anyway the problem is not in EGit.
detectors.remove();
}
if (newLinks != null && newLinks.length > 0) {
Collections.addAll(hyperlinksOnLine, newLinks);
}
}
// Sort them by offset, and with increasing length
Collections.sort(hyperlinksOnLine,
new Comparator<IHyperlink>() {
@Override
public int compare(IHyperlink a, IHyperlink b) {
int diff = a.getHyperlinkRegion()
.getOffset()
- b.getHyperlinkRegion()
.getOffset();
if (diff != 0) {
return diff;
}
return a.getHyperlinkRegion().getLength()
- b.getHyperlinkRegion()
.getLength();
}
});
lastLineStart = currentLine.getOffset();
}
if (!hyperlinksOnLine.isEmpty()) {
// Find first hyperlink for the position. We may have to
// skip a few in case there are several hyperlinks at the
// same position and with the same length.
Iterator<IHyperlink> iterator = hyperlinksOnLine.iterator();
while (iterator.hasNext()) {
IHyperlink next = iterator.next();
IRegion linkRegion = next.getHyperlinkRegion();
int linkEnd = linkRegion.getOffset()
+ linkRegion.getLength();
if (currentOffset >= linkEnd) {
iterator.remove();
} else if (linkRegion.getOffset() <= currentOffset) {
// This is our link
iterator.remove();
int end = Math.min(endOfRange, linkEnd);
if (end > currentOffset) {
currentOffset = end;
return hyperlinkToken;
}
} else {
// Next hyperlink is beyond current position
break;
}
}
}
} catch (BadLocationException e) {
// Ignore and keep going
}
}
int actualOffset = currentOffset;
IToken token = scanToken();
if (token != null && actualOffset < currentOffset) {
return token;
}
currentOffset = actualOffset + 1;
return defaultToken;
}
@Override
public int getTokenOffset() {
return tokenStart;
}
@Override
public int getTokenLength() {
return currentOffset - tokenStart;
}
/**
* Configures the scanner by providing access to the document range that
* should be scanned, plus defining the foreground color to use for
* hyperlink syntax coloring.
*
* @param document
* the document to scan
* @param offset
* the offset of the document range to scan
* @param length
* the length of the document range to scan
* @param color
* the foreground color to use for hyperlinks; may be
* {@code null} in which case the default color is applied
*/
protected void setRangeAndColor(@NonNull IDocument document, int offset,
int length, @Nullable Color color) {
Assert.isTrue(document == viewer.getDocument());
this.document = document;
this.lastLineStart = -1;
this.endOfRange = offset + length;
this.currentOffset = offset;
this.tokenStart = -1;
this.hyperlinkToken = new Token(
new TextAttribute(color, null, TextAttribute.UNDERLINE));
this.hyperlinkDetectors = getHyperlinkDetectors();
}
/**
* Invoked if there is no hyperlink at the current position; may check for
* additional tokens. If a token is found, must advance currentOffset and
* return the token.
*
* @return the {@link IToken}, or {@code null} if none.
*/
protected IToken scanToken() {
return null;
}
private @NonNull Set<IHyperlinkDetector> getHyperlinkDetectors() {
Set<IHyperlinkDetector> allDetectors = new LinkedHashSet<>();
IHyperlinkDetector[] configuredDetectors = configuration
.getHyperlinkDetectors(viewer);
if (configuredDetectors != null && configuredDetectors.length > 0) {
for (IHyperlinkDetector detector : configuredDetectors) {
allDetectors.add(detector);
}
if (preferenceStore.getBoolean(URL_HYPERLINK_DETECTOR_KEY)
|| !preferenceStore.getBoolean(
AbstractTextEditor.PREFERENCE_HYPERLINKS_ENABLED)) {
return allDetectors;
}
// URLHyperlinkDetector can only detect hyperlinks at the start of
// the range. We need one that can detect all hyperlinks in a given
// region.
allDetectors.add(new MultiURLHyperlinkDetector());
}
return allDetectors;
}
/**
* A {@link URLHyperlinkDetector} that returns all hyperlinks in a region.
* <p>
* This internal class assumes that the region is either empty or else spans
* a whole line.
* </p>
*/
private static class MultiURLHyperlinkDetector
extends URLHyperlinkDetector {
@Override
public IHyperlink[] detectHyperlinks(ITextViewer textViewer,
IRegion region, boolean canShowMultipleHyperlinks) {
if (region.getLength() == 0) {
return super.detectHyperlinks(textViewer, region,
canShowMultipleHyperlinks);
}
// URLHyperlinkDetector only finds hyperlinks at the region start.
// We know here that the given region spans a whole line since we're
// only called from HyperlinkTokenScanner.nextToken().
try {
String line = textViewer.getDocument().get(region.getOffset(),
region.getLength());
int currentOffset = region.getOffset();
int lineStart = currentOffset;
int regionEnd = currentOffset + region.getLength();
List<IHyperlink> allLinks = new ArrayList<>();
while (currentOffset < regionEnd) {
IHyperlink[] newLinks = super.detectHyperlinks(
textViewer, new Region(currentOffset, 0),
canShowMultipleHyperlinks);
currentOffset++;
if (newLinks != null && newLinks.length > 0) {
Collections.addAll(allLinks, newLinks);
for (IHyperlink link : newLinks) {
int end = link.getHyperlinkRegion().getOffset()
+ link.getHyperlinkRegion().getLength();
if (end > currentOffset) {
currentOffset = end;
}
}
}
// Advance to the next "://" combination.
int nextCandidatePos = lineStart
+ line.indexOf("://", currentOffset - lineStart); //$NON-NLS-1$
if (nextCandidatePos > currentOffset) {
currentOffset = nextCandidatePos;
} else if (nextCandidatePos < currentOffset) {
// No more links.
break;
}
}
return allLinks.toArray(new IHyperlink[allLinks.size()]);
} catch (BadLocationException e) {
return null;
}
}
}
}