/*
* The MIT License (MIT)
*
* Copyright (c) 2017 hsz Jakub Chrzanowski <jakub@hsz.mobi>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package mobi.hsz.idea.gitignore.util;
import com.intellij.concurrency.JobScheduler;
import com.intellij.dvcs.repo.Repository;
import com.intellij.dvcs.repo.VcsRepositoryManager;
import com.intellij.ide.projectView.impl.AbstractProjectViewPane;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.extensions.Extensions;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vcs.FileStatusManager;
import com.intellij.openapi.vcs.ProjectLevelVcsManager;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.VirtualFileWithId;
import com.intellij.testFramework.LightVirtualFile;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.containers.HashMap;
import com.intellij.util.messages.MessageBus;
import git4idea.repo.GitRepository;
import mobi.hsz.idea.gitignore.IgnoreManager;
import mobi.hsz.idea.gitignore.psi.IgnoreEntry;
import mobi.hsz.idea.gitignore.psi.IgnoreFile;
import mobi.hsz.idea.gitignore.psi.IgnoreVisitor;
import mobi.hsz.idea.gitignore.util.exec.ExternalExec;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static mobi.hsz.idea.gitignore.IgnoreManager.RefreshTrackedIgnoredListener.TRACKED_IGNORED_REFRESH;
import static mobi.hsz.idea.gitignore.IgnoreManager.TrackedIgnoredListener.TRACKED_IGNORED;
/**
* {@link HashMap} cache helper.
*
* @author Jakub Chrzanowski <jakub@hsz.mobi>
* @since 1.0.2
*/
public class CacheMap {
/** Main cache map instance. */
@NotNull
private final ConcurrentMap<IgnoreFile, Pair<Set<Integer>, List<Pair<Matcher, Boolean>>>> map =
ContainerUtil.newConcurrentMap();
/** Cache {@link HashMap} to store files statuses. */
@NotNull
private final HashMap<VirtualFile, Status> statuses = new HashMap<VirtualFile, Status>();
/** List of the files that are ignored and also tracked by Git. */
@NotNull
private final HashMap<VirtualFile, Repository> trackedIgnoredFiles = new HashMap<VirtualFile, Repository>();
/** Current project. */
@NotNull
private final Project project;
/** {@link FileStatusManager} instance. */
@NotNull
private final FileStatusManager statusManager;
/** {@link MessageBus} instance. */
@NotNull
private final MessageBus messageBus;
/** Task to fetch tracked and ignored files using Git repositories. */
@NotNull
private TrackedIgnoredFilesRunnable trackedIgnoredFilesRunnable = new TrackedIgnoredFilesRunnable();
/** Timer for {@link #trackedIgnoredFilesRunnable}. */
@Nullable
private ScheduledFuture<?> trackedIgnoredFilesTimer;
/**
* Returns tracked and ignored files stored in {@link #trackedIgnoredFiles}.
*
* @return tracked and ignored files map
*/
@NotNull
public HashMap<VirtualFile, Repository> getTrackedIgnoredFiles() {
return trackedIgnoredFiles;
}
/** Status of the file. */
private enum Status {
IGNORED, UNIGNORED, UNTOUCHED
}
/** Constructor. */
public CacheMap(@NotNull Project project) {
this.project = project;
this.statusManager = FileStatusManager.getInstance(project);
this.messageBus = project.getMessageBus();
this.messageBus.connect().subscribe(TRACKED_IGNORED_REFRESH, trackedIgnoredFilesRunnable);
}
/**
* Adds new {@link IgnoreFile} to the cache and builds its hashCode and patterns sets.
*
* @param file to add
*/
public void add(@NotNull final IgnoreFile file) {
final Set<Integer> set = ContainerUtil.newHashSet();
runVisitorInReadAction(file, new IgnoreVisitor() {
@Override
public void visitEntry(@NotNull IgnoreEntry entry) {
set.add(entry.getText().trim().hashCode());
}
});
add(file, set);
}
/**
* Adds new {@link IgnoreFile} to the cache and builds its hashCode and patterns sets.
*
* @param file to add
* @param set entries hashCodes set
*/
private void add(@NotNull final IgnoreFile file, @NotNull Set<Integer> set) {
final List<Pair<Matcher, Boolean>> matchers = ContainerUtil.newArrayList();
runVisitorInReadAction(file, new IgnoreVisitor() {
@Override
public void visitEntry(@NotNull IgnoreEntry entry) {
Pattern pattern = Glob.createPattern(entry);
if (pattern != null) {
matchers.add(Pair.create(pattern.matcher(""), entry.isNegated()));
}
}
});
map.put(file, Pair.create(set, matchers));
refresh();
}
/**
* Looks for given {@link VirtualFile} and removes it from the cache map.
*
* @param file to remove
*/
public void cleanup(@NotNull VirtualFile file) {
for (IgnoreFile ignoreFile : map.keySet()) {
if (ignoreFile.getVirtualFile().equals(file)) {
map.remove(ignoreFile);
}
}
refresh();
}
/** Clears cache. */
public void clear() {
map.clear();
refresh();
}
/** Refreshes statuses and {@link #trackedIgnoredFiles} list. */
public void refresh() {
statuses.clear();
fetchTrackedIgnoredFiles();
statusManager.fileStatusesChanged();
}
/**
* Method loops over {@link GitRepository} repositories and checks if they contain any of
* tracked files that are also ignored with .gitignore files.
*/
private void fetchTrackedIgnoredFiles() {
if (trackedIgnoredFilesTimer != null) {
trackedIgnoredFilesTimer.cancel(false);
}
trackedIgnoredFilesTimer = JobScheduler.getScheduler().schedule(
trackedIgnoredFilesRunnable,
1000,
TimeUnit.MILLISECONDS
);
}
/**
* Checks if {@link IgnoreFile} has changed and rebuilds its cache.
*
* @param file to check
*/
public void hasChanged(@NotNull IgnoreFile file) {
final Pair<Set<Integer>, List<Pair<Matcher, Boolean>>> recent = map.get(file);
final Set<Integer> set = ContainerUtil.newHashSet();
file.acceptChildren(new IgnoreVisitor() {
@Override
public void visitEntry(@NotNull IgnoreEntry entry) {
set.add(entry.getText().trim().hashCode());
}
});
if (recent == null || !set.equals(recent.getFirst())) {
add(file, set);
}
}
/**
* Simple wrapper for running read action
*
* @param file {@link IgnoreFile} to run visitor on it
* @param visitor {@link IgnoreVisitor}
*/
private void runVisitorInReadAction(@NotNull final IgnoreFile file, @NotNull final IgnoreVisitor visitor) {
ApplicationManager.getApplication().runReadAction(new Runnable() {
public void run() {
VirtualFile virtualFile = file.getVirtualFile();
if (virtualFile != null && (virtualFile instanceof LightVirtualFile ||
(virtualFile instanceof VirtualFileWithId && ((VirtualFileWithId) virtualFile).getId() > 0))) {
file.acceptChildren(visitor);
}
}
});
}
/**
* Checks if given {@link VirtualFile} is ignored.
*
* @param file to check
* @return file is ignored
*/
public boolean isFileIgnored(@NotNull VirtualFile file) {
Status status = statuses.get(file);
if (status == null || status.equals(Status.UNTOUCHED)) {
status = getParentStatus(file);
statuses.put(file, status);
}
final List<IgnoreFile> files =
ContainerUtil.reverse(Utils.ignoreFilesSort(ContainerUtil.newArrayList(map.keySet())));
final ProjectLevelVcsManager projectLevelVcsManager = ProjectLevelVcsManager.getInstance(project);
for (final IgnoreFile ignoreFile : files) {
boolean outer = ignoreFile.isOuter();
final VirtualFile ignoreFileParent = ignoreFile.getVirtualFile().getParent();
if (!outer) {
if (ignoreFileParent == null || !Utils.isUnder(file, ignoreFileParent)) {
continue;
}
VirtualFile vcsRoot = projectLevelVcsManager.getVcsRootFor(file);
if (vcsRoot != null && !vcsRoot.equals(file) && !Utils.isUnder(ignoreFile.getVirtualFile(), vcsRoot)) {
continue;
}
}
final String path = Utils.getRelativePath(outer ? project.getBaseDir() : ignoreFileParent, file);
if (StringUtil.isEmpty(path)) {
continue;
}
List<Pair<Matcher, Boolean>> matchers = map.get(ignoreFile).getSecond();
for (Pair<Matcher, Boolean> pair : ContainerUtil.reverse(matchers)) {
if (MatcherUtil.match(pair.getFirst(), path)) {
status = pair.getSecond() ? Status.UNIGNORED : Status.IGNORED;
break;
}
}
if (!status.equals(Status.UNTOUCHED)) {
break;
}
}
statuses.put(file, status);
return status.equals(Status.IGNORED);
}
/**
* Checks if given {@link VirtualFile} is ignored and still tracked.
*
* @param file to check
* @return file is ignored and tracked
*/
public boolean isFileTrackedIgnored(@NotNull VirtualFile file) {
return trackedIgnoredFiles.keySet().contains(file);
}
/**
* Returns the status of the parent.
*
* @param file to check
* @return any of the parents is ignored
*/
@NotNull
private Status getParentStatus(@NotNull VirtualFile file) {
VirtualFile parent = file.getParent();
VirtualFile vcsRoot = ProjectLevelVcsManager.getInstance(project).getVcsRootFor(file);
while (parent != null && !parent.equals(project.getBaseDir()) && (vcsRoot == null || !vcsRoot.equals(parent))) {
final Status status = statuses.get(parent);
if (status != null) {
return status;
}
parent = parent.getParent();
}
return Status.UNTOUCHED;
}
/** {@link Runnable} implementation to rebuild {@link #trackedIgnoredFiles}. */
class TrackedIgnoredFilesRunnable implements Runnable, IgnoreManager.RefreshTrackedIgnoredListener {
/** Default {@link Runnable} run method that invokes rebuilding with bus event propagating. */
@Override
public void run() {
run(false);
}
/** Rebuilds {@link #trackedIgnoredFiles} map in silent mode. */
@Override
public void refresh() {
this.run(true);
}
/**
* Rebuilds {@link #trackedIgnoredFiles} map.
*
* @param silent propagate {@link IgnoreManager.TrackedIgnoredListener#TRACKED_IGNORED} event
*/
public void run(boolean silent) {
final Collection<Repository> repositories = VcsRepositoryManager.getInstance(project).getRepositories();
final HashMap<VirtualFile, Repository> result = new HashMap<VirtualFile, Repository>();
for (Repository repository : repositories) {
if (!(repository instanceof GitRepository)) {
continue;
}
VirtualFile root = repository.getRoot();
for (String path : ExternalExec.getTrackedIgnoredFiles(repository)) {
final VirtualFile file = root.findFileByRelativePath(path);
if (file != null) {
result.put(file, repository);
}
}
}
if (!silent && !result.isEmpty()) {
messageBus.syncPublisher(TRACKED_IGNORED).handleFiles(result);
}
trackedIgnoredFiles.clear();
trackedIgnoredFiles.putAll(result);
statusManager.fileStatusesChanged();
for (AbstractProjectViewPane pane : Extensions.getExtensions(AbstractProjectViewPane.EP_NAME, project)) {
if (pane.getTreeBuilder() != null) {
pane.getTreeBuilder().queueUpdate();
}
}
}
}
}