// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.collide.client.code.parenmatch;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.editor.ViewportModel;
import com.google.collide.client.editor.renderer.LineRenderer;
import com.google.collide.client.editor.renderer.Renderer;
import com.google.collide.client.editor.renderer.SingleChunkLineRenderer;
import com.google.collide.client.editor.search.SearchTask;
import com.google.collide.client.editor.search.SearchTask.SearchDirection;
import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.client.editor.selection.SelectionModel.CursorListener;
import com.google.collide.client.util.BasicIncrementalScheduler;
import com.google.collide.client.util.IncrementalScheduler;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.anchor.Anchor;
import com.google.collide.shared.document.anchor.AnchorManager;
import com.google.collide.shared.document.anchor.AnchorType;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.ListenerRegistrar;
import com.google.collide.shared.util.RegExpUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.gwt.regexp.shared.MatchResult;
import com.google.gwt.regexp.shared.RegExp;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.user.client.Timer;
/*
* TODO : Make this language specific and utilize code
* understanding.
*/
/**
* Highlights matching character for (), {}, [], and <> when the cursor is next
* to one of them.
*/
public class ParenMatchHighlighter {
/** Opening paren characters. */
public static final String OPEN_PARENS = "(<[{";
/**
* Closing paren characters. {@link #CLOSE_PARENS}[i] must be the closing
* match for {@link #OPEN_PARENS}[i].
*/
public static final String CLOSE_PARENS = ")>]}";
static final AnchorType MATCH_ANCHOR_TYPE =
AnchorType.create(ParenMatchHighlighter.class, "matchAnchor");
/**
* Paren match highlighting CSS.
*/
public interface Css extends Editor.EditorSharedCss {
String match();
}
/**
* Paren match highlighting resources.
*/
public interface Resources extends ClientBundle {
@Source({"ParenMatchHighlighter.css", "com/google/collide/client/common/constants.css"})
Css parenMatchHighlighterCss();
}
/**
* Handler for processing each line during the search.
*/
private class SearchTaskHandler implements SearchTask.SearchTaskExecutor {
private Line startLine;
private int cursorColumn;
private SearchDirection direction;
private char searchChar;
private char cancelChar;
private int matchCount;
private RegExp regExp;
/**
* Initialize the search parameters.
*
* @param startLine the line the search will start at.
* @param cursorColumn the column where the paren character was found.
* @param direction the direction to search, depending on whether we're
* looking for the closing or opening paren.
* @param searchChar the char we are looking for that opens or closes the
* found paren
* @param cancelChar the char we found. If found again, we must find it's
* match before we find the original paren's match.
*/
public void initialize(Line startLine, int cursorColumn, SearchDirection direction,
char searchChar, char cancelChar) {
this.startLine = startLine;
this.cursorColumn = cursorColumn;
this.direction = direction;
this.searchChar = searchChar;
this.cancelChar = cancelChar;
if (searchChar == '[' || searchChar == ']') {
this.regExp = RegExp.compile("\\[|\\]", "g");
} else {
// searching ( or ) -> [(]|[)]
this.regExp = RegExp.compile("[" + this.searchChar + "]|[" + this.cancelChar + "]", "g");
}
// we start at 1 and try to get down to 0 by finding the actual match.
this.matchCount = 1;
}
@Override
public boolean onSearchLine(Line line, int number, boolean shouldRenderLine) {
String lineText= line.getText();
/*
* Set match to -1 since we call getNextMatch with match + 1 to exclude a
* found match from the next round.
*/
int match = -1;
if (direction == SearchDirection.DOWN) {
if (line == startLine) {
// the - 1 is to make sure we include the character at the cursor.
match = cursorColumn - 1;
}
MatchResult result;
while ((result = RegExpUtils.findMatchAfterIndex(regExp, lineText, match)) != null) {
match = result.getIndex();
if (checkForMatch(line, number, match, shouldRenderLine)) {
return false;
}
}
} else {
int endColumn = line.length() - 1;
if (line == startLine) {
endColumn = cursorColumn - 2;
}
// first get all matches
JsonArray<Integer> matches = JsonCollections.createArray();
MatchResult result;
while ((result = RegExpUtils.findMatchAfterIndex(regExp, lineText, match)) != null) {
match = result.getIndex();
if (match <= endColumn) {
matches.add(match);
} else {
break;
}
}
// then iterate backwards through them
/**
* TODO : look for a faster way to do this such that we
* don't have to iterate back through them
*/
for (int i = matches.size() - 1; i >= 0; i--) {
if (checkForMatch(line, number, matches.get(i), shouldRenderLine)) {
return false;
}
}
}
return true;
}
/**
* Check if this character is the match we are looking for.
*/
private boolean checkForMatch(Line line, int number, int column, boolean shouldRenderLine) {
char nextChar = line.getText().charAt(column);
if (nextChar == searchChar) {
matchCount--;
} else if (nextChar == cancelChar) {
matchCount++;
}
if (matchCount == 0) {
matchAnchor = anchorManager.createAnchor(MATCH_ANCHOR_TYPE, line, number, column);
// when testing, css is null
matchRenderer = SingleChunkLineRenderer.create(matchAnchor, matchAnchor, css.match());
renderer.addLineRenderer(matchRenderer);
if (shouldRenderLine) {
renderer.requestRenderLine(line);
}
return true;
}
return false;
}
}
/**
* A helper class to handle client events and listeners. This allows all client and GWT
* functionality to be mocked out in the tests by hiding the implementation details of the
* {@link Timer}.
*/
static class ParenMatchHelper implements ListenerRegistrar<CursorListener> {
CursorListener cursorListener;
Remover remover;
final SelectionModel selectionModel;
final Timer timer = new Timer() {
@Override
public void run() {
if (cursorListener == null) {
return;
}
LineInfo cursorLine =
new LineInfo(selectionModel.getCursorLine(), selectionModel.getCursorLineNumber());
int cursorColumn = selectionModel.getCursorColumn();
cursorListener.onCursorChange(cursorLine, cursorColumn, true);
}
};
public ParenMatchHelper(SelectionModel model) {
this.selectionModel = model;
}
void register() {
remover = selectionModel.getCursorListenerRegistrar().add(new CursorListener() {
@Override
public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) {
timer.schedule(50);
}
});
}
void cancelTimer() {
timer.cancel();
}
@Override
public ListenerRegistrar.Remover add(CursorListener listener) {
Preconditions.checkArgument(this.cursorListener == null, "Can't register two listeners");
this.cursorListener = listener;
register();
return new Remover() {
@Override
public void remove() {
remover.remove();
cursorListener = null;
}
};
}
@Override
public void remove(CursorListener listener) {
throw new UnsupportedOperationException("The remover must be used to remove the listener");
}
}
public static ParenMatchHighlighter create(
Document document,
ViewportModel viewportModel,
AnchorManager anchorManager,
Resources res,
Renderer renderer,
final SelectionModel selection) {
final IncrementalScheduler scheduler = new BasicIncrementalScheduler(100, 5000);
ParenMatchHelper helper = new ParenMatchHelper(selection);
return new ParenMatchHighlighter(
document, viewportModel, anchorManager, res, renderer, scheduler, helper);
}
private final AnchorManager anchorManager;
private final Renderer renderer;
private final IncrementalScheduler scheduler;
private final SearchTask searchTask;
private final SearchTaskHandler searchTaskHandler;
private final Css css;
private final ListenerRegistrar<CursorListener> listenerRegistrar;
private final CursorListener cursorListener;
private ListenerRegistrar.Remover cursorListenerRemover;
private Anchor matchAnchor;
private LineRenderer matchRenderer;
@VisibleForTesting
ParenMatchHighlighter(Document document, ViewportModel viewportModel,
AnchorManager anchorManager, Resources res, Renderer renderer,
IncrementalScheduler scheduler, ListenerRegistrar<CursorListener> listenerRegistrar) {
this.anchorManager = anchorManager;
this.renderer = renderer;
this.scheduler = scheduler;
this.listenerRegistrar = listenerRegistrar;
searchTask = new SearchTask(document, viewportModel, scheduler);
searchTaskHandler = new SearchTaskHandler();
css = res.parenMatchHighlighterCss();
cursorListener = new CursorListener() {
@Override
public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) {
cancel();
maybeSearch(lineInfo, column);
}
};
cursorListenerRemover = this.listenerRegistrar.add(cursorListener);
}
/**
* Enable or disable the match highlighter. By default it's enabled.
*/
public void setEnabled(boolean enabled) {
Preconditions.checkNotNull(
cursorListenerRemover, "can't enable when cursorListenerRemover is null");
if (enabled) {
cursorListenerRemover = listenerRegistrar.add(cursorListener);
} else {
cancel();
cursorListenerRemover.remove();
}
}
/**
* Cancel the current matching - both the search and any displayed matches.
*/
public void cancel() {
scheduler.cancel();
searchTask.cancelTask();
if (matchRenderer != null) {
renderer.removeLineRenderer(matchRenderer);
renderer.requestRenderLine(matchAnchor.getLine());
anchorManager.removeAnchor(matchAnchor);
matchRenderer = null;
}
}
public void teardown() {
cancel();
cursorListenerRemover.remove();
}
/**
* Checks if there is a paren to match at the cursor and starts a search if
* so.
*
* @param cursorLine
* @param cursorColumn
*/
private void maybeSearch(LineInfo cursorLine, int cursorColumn) {
if (cursorColumn > 0) {
char cancelChar = cursorLine.line().getText().charAt(cursorColumn - 1);
int openIndex = OPEN_PARENS.indexOf(cancelChar);
if (openIndex >= 0) {
search(SearchDirection.DOWN, CLOSE_PARENS.charAt(openIndex), cancelChar, cursorLine,
cursorColumn);
return;
}
int closeIndex = CLOSE_PARENS.indexOf(cancelChar);
if (closeIndex >= 0) {
search(SearchDirection.UP, OPEN_PARENS.charAt(closeIndex), cancelChar, cursorLine,
cursorColumn);
return;
}
}
}
/**
* Starts the search for the matching paren.
*
* @param direction the direction to search in
* @param searchChar the character we want to match
* @param cancelChar the character we found, which if we find again we need to
* find its match first.
* @param cursorLine the line where the to-be-matched character was found
* @param column the column to the right of the to-be-matched character
*/
@VisibleForTesting
protected void search(final SearchDirection direction, final char searchChar,
final char cancelChar, final LineInfo cursorLine, final int column) {
searchTaskHandler.initialize(cursorLine.line(), column, direction, searchChar, cancelChar);
searchTask.searchDocumentStartingAtLine(searchTaskHandler, null, direction, cursorLine);
}
}