/* * 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.codeInspection; import com.intellij.codeInspection.InspectionManager; import com.intellij.codeInspection.LocalInspectionTool; import com.intellij.codeInspection.ProblemDescriptor; import com.intellij.codeInspection.ProblemsHolder; import com.intellij.openapi.editor.Document; import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Pair; import com.intellij.openapi.vfs.*; import com.intellij.psi.PsiFile; import com.intellij.util.containers.ContainerUtil; import mobi.hsz.idea.gitignore.IgnoreBundle; 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.Constants; import mobi.hsz.idea.gitignore.util.Glob; import mobi.hsz.idea.gitignore.util.Utils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentMap; /** * Inspection tool that checks if entries are covered by others. * * @author Jakub Chrzanowski <jakub@hsz.mobi> * @since 0.5 */ public class IgnoreCoverEntryInspection extends LocalInspectionTool { /** Cache map to store handled entries' paths. */ private final ConcurrentMap<String, Set<String>> cacheMap; /** {@link VirtualFileManager} instance. */ private final VirtualFileManager virtualFileManager; /** Watches for the changes in the files tree and triggers the cache clear. */ private final VirtualFileListener virtualFileListener = new VirtualFileAdapter() { @Override public void propertyChanged(@NotNull VirtualFilePropertyEvent event) { if (event.getPropertyName().equals("name")) { cacheMap.clear(); } } @Override public void fileCreated(@NotNull VirtualFileEvent event) { cacheMap.clear(); } @Override public void fileDeleted(@NotNull VirtualFileEvent event) { cacheMap.clear(); } @Override public void fileMoved(@NotNull VirtualFileMoveEvent event) { cacheMap.clear(); } @Override public void fileCopied(@NotNull VirtualFileCopyEvent event) { cacheMap.clear(); } }; /** * Builds a new instance of {@link IgnoreCoverEntryInspection}. * Initializes {@link VirtualFileManager} and listens for the changes in the files tree. */ public IgnoreCoverEntryInspection() { cacheMap = ContainerUtil.newConcurrentMap(); virtualFileManager = VirtualFileManager.getInstance(); // virtualFileManager.addVirtualFileListener(virtualFileListener); } /** * Unregisters {@link #virtualFileListener} and clears the paths cache. * * @param project current project */ @Override public void cleanup(@NotNull Project project) { virtualFileManager.removeVirtualFileListener(virtualFileListener); cacheMap.clear(); } /** * Reports problems at file level. Checks if entries are covered by other entries. * * @param file current working file to check * @param manager {@link InspectionManager} to ask for {@link ProblemDescriptor}'s from * @param isOnTheFly true if called during on the fly editor highlighting. Called from Inspect Code action * otherwise * @return <code>null</code> if no problems found or not applicable at file level */ @Nullable @Override public ProblemDescriptor[] checkFile(@NotNull PsiFile file, @NotNull InspectionManager manager, boolean isOnTheFly) { final VirtualFile virtualFile = file.getVirtualFile(); if (!(file instanceof IgnoreFile) || !Utils.isInProject(virtualFile, file.getProject())) { return null; } final VirtualFile contextDirectory = virtualFile.getParent(); if (contextDirectory == null) { return null; } final Set<String> ignored = ContainerUtil.newHashSet(); final Set<String> unignored = ContainerUtil.newHashSet(); final ProblemsHolder problemsHolder = new ProblemsHolder(manager, file, isOnTheFly); final List<Pair<IgnoreEntry, IgnoreEntry>> entries = ContainerUtil.newArrayList(); final Map<IgnoreEntry, Set<String>> map = ContainerUtil.newHashMap(); file.acceptChildren(new IgnoreVisitor() { @Override public void visitEntry(@NotNull IgnoreEntry entry) { Set<String> matched = getPathsSet(contextDirectory, entry); Collection<String> intersection; boolean modified; if (!entry.isNegated()) { ignored.addAll(matched); intersection = Utils.intersection(unignored, matched); modified = unignored.removeAll(intersection); } else { unignored.addAll(matched); intersection = Utils.intersection(ignored, matched); modified = ignored.removeAll(intersection); } if (modified) { return; } for (IgnoreEntry recent : map.keySet()) { Set<String> recentValues = map.get(recent); if (recentValues.isEmpty() || matched.isEmpty()) { continue; } if (entry.isNegated() == recent.isNegated()) { if (recentValues.containsAll(matched)) { entries.add(Pair.create(recent, entry)); } else if (matched.containsAll(recentValues)) { entries.add(Pair.create(entry, recent)); } } else { if (intersection.containsAll(recentValues)) { entries.add(Pair.create(entry, recent)); } } } map.put(entry, matched); } }); for (Pair<IgnoreEntry, IgnoreEntry> pair : entries) { problemsHolder.registerProblem(pair.second, message(pair.first, virtualFile, isOnTheFly), new IgnoreRemoveEntryFix(pair.second)); } return problemsHolder.getResultsArray(); } /** * Returns the paths list for the given {@link IgnoreEntry} in {@link VirtualFile} context. * Stores fetched data in {@link #cacheMap} to limit the queries to the files tree. * * @param contextDirectory current context * @param entry to check * @return paths list */ @NotNull private Set<String> getPathsSet(@NotNull VirtualFile contextDirectory, @NotNull IgnoreEntry entry) { final String key = contextDirectory.getPath() + Constants.DOLLAR + entry.getText(); if (!cacheMap.containsKey(key)) { cacheMap.put(key, ContainerUtil.newHashSet(Glob.findAsPaths(contextDirectory, entry, true))); } return cacheMap.get(key); } /** * Helper for inspection message generating. * * @param coveringEntry entry that covers message related * @param virtualFile current working file * @param onTheFly true if called during on the fly editor highlighting. Called from Inspect Code action * otherwise * @return generated message {@link String} */ @NotNull private static String message(@NotNull IgnoreEntry coveringEntry, @NotNull VirtualFile virtualFile, boolean onTheFly) { Document document = FileDocumentManager.getInstance().getDocument(virtualFile); if (onTheFly || document == null) { return IgnoreBundle.message( "codeInspection.coverEntry.message", "\'" + coveringEntry.getText() + "\'" ); } int startOffset = coveringEntry.getTextRange().getStartOffset(); return IgnoreBundle.message( "codeInspection.coverEntry.message", "<a href=\"" + virtualFile.getUrl() + Constants.HASH + startOffset + "\">" + coveringEntry.getText() + "</a>" ); } /** * Forces checking every entry in checked file. * * @return <code>true</code> */ @Override public boolean runForWholeFile() { return true; } }