/*
* Copyright 2012 Andrew C. Dvorak.
*
* 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 net.andydvorak.intellij.lessc.fs;
import com.asual.lesscss.LessEngine;
import com.asual.lesscss.LessException;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import net.andydvorak.intellij.lessc.state.CssDirectory;
import net.andydvorak.intellij.lessc.state.LessProfile;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class LessFile extends File implements Comparable<File> {
private static final Pattern LESS_IMPORT_PATTERN = Pattern.compile("@import \"([^\"]+)\";");
private static final Pattern LESS_FILENAME_PATTERN = Pattern.compile(".*\\.less$");
private static final Logger LOG = Logger.getInstance("#" + LessFile.class.getName());
private final AtomicBoolean cssChanged = new AtomicBoolean();
public LessFile(final String path) {
this(new File(path));
}
public LessFile(final String parent, final String child) {
this(new File(parent, child));
}
public LessFile(final URI uri) {
this(new File(uri));
}
public LessFile(final File file) {
super(getCanonicalPathSafe(file));
}
/*
* Public instance methods
*/
public boolean hasCssChanged() {
return cssChanged.get();
}
/**
* Similar to {@link #getCanonicalPath()}, but falls back to returning {@link #getAbsolutePath()} instead of throwing an {@link IOException}.
* @return the canonical path to the {@code File} if it exists; otherwise the absolute path
*/
public String getCanonicalPathSafe() {
return getCanonicalPathSafe(this);
}
/**
* Similar to {@link #getCanonicalPath()}, but falls back to returning {@link #getAbsolutePath()} instead of throwing an {@link IOException}.
* Any HTML Entities in the resulting path will be escaped (encoded) so that the path is safe to insert into HTML code.
* @return the canonical, HTML-encoded path to the {@code File} if it exists; otherwise the absolute path
*/
public String getCanonicalPathSafeHtmlEscaped() {
return StringEscapeUtils.escapeHtml4(getCanonicalPathSafe());
}
@NotNull
public List<LessProfile> getLessProfiles(final Collection<LessProfile> lessProfiles) {
final List<LessProfile> profiles = new ArrayList<LessProfile>();
for (final LessProfile lessProfile : lessProfiles) {
final File lessProfileDir = new File(lessProfile.getLessDirPath());
if (lessProfileDir.exists() && FileUtil.isAncestor(lessProfileDir, this, false)) {
profiles.add(lessProfile);
}
}
return profiles;
}
public boolean shouldCompile(@Nullable final LessProfile lessProfile) {
if (lessProfile == null || !lessProfile.hasCssDirectories())
return false;
final List<String> includePatterns = normalizePatterns(lessProfile.getIncludePattern());
final List<String> excludePatterns = normalizePatterns(lessProfile.getExcludePattern());
boolean include = includePatterns.isEmpty();
boolean exclude = false;
for (final String includePattern : includePatterns) {
include |= matches(includePattern);
}
for (final String excludePattern : excludePatterns) {
exclude |= matches(excludePattern);
}
return include && !exclude;
}
public boolean matches(final String pattern) {
return FilenameUtils.wildcardMatchOnSystem(getCanonicalPathSafe(), makePatternAbsolute(pattern));
}
public boolean equals(@Nullable final LessFile that) {
return isSameFile(this, that);
}
public boolean notEquals(@Nullable final LessFile that) {
return !this.equals(that);
}
/**
* Returns all files that directly {@code @import} the current LESS file.
* @param lessProfile profile to search for dependents
* @return all files that directly {@code @import} the current LESS file.
* @throws IOException
*/
@NotNull
public Set<LessFile> getFirstLevelDependents(@NotNull final LessProfile lessProfile) throws IOException {
final LessFile parentLessFile = this;
return getLessFiles(lessProfile, new Filter() {
@Override
public boolean accept(@NotNull final LessFile lessFile) throws IOException {
return lessFile.notEquals(parentLessFile) &&
// lessFile.shouldCompile(lessProfile) && // this prevents us from finding compilable dependents in uncompilable files
lessFile.imports(parentLessFile);
}
});
}
/**
* Returns all compilable dependents of the current LESS file found in the given profile (recursive).
* If a <em>non-compilable</em> file directly {@code @import}s the current LESS file, and a separate
* <em>compilable</em> file {@code @import}s the non-compilable file, the compilable file will be included
* in the returned set but the non-compilable file will not.
* @param lessProfile profile to search for dependents
* @return all compilable dependent LESS files
* @throws IOException
*/
@NotNull
public Set<LessFile> getDependentsRecursive(@NotNull final LessProfile lessProfile) throws IOException {
final Set<LessFile> dependents = getDependentsRecursive(lessProfile, new LinkedHashSet<LessFile>());
LOG.debug("Dependency tree for " + getName() + " contains " + dependents.size() + " files");
final Set<LessFile> compilable = filter(dependents, new Filter() {
@Override
public boolean accept(@NotNull final LessFile lessFile) throws IOException {
return lessFile.shouldCompile(lessProfile);
}
});
LOG.debug("Found for " + getName() + " contains " + dependents.size() + " files");
return compilable;
}
/**
* Determines if the current LESS file contains an {@code @import} that resolves to the given LESS file.
* @param importedLessFile LESS file to search for in the current file's {@code @import}s
* @return {@code true} if the current LESS file contains an {@code @import} that resolves to the given LESS file;
* otherwise {@code false}
* @throws IOException
*/
public boolean imports(@NotNull final LessFile importedLessFile) throws IOException {
final Set<LessFile> matchingImports = getImports(new Filter() {
@Override
public boolean accept(@NotNull final LessFile lessFile) {
return importedLessFile.equals(lessFile);
}
});
return !matchingImports.isEmpty();
}
@NotNull
public Set<LessFile> getImports(@NotNull final Filter filter) throws IOException {
final Set<LessFile> imports = new LinkedHashSet<LessFile>();
final Matcher importMatcher = LESS_IMPORT_PATTERN.matcher(FileUtil.loadFile(this));
while (importMatcher.find()) {
final LessFile lessFile = new LessFile(getParent(), resolveImportFileName(importMatcher.group(1)));
if (filter.accept(lessFile)) {
imports.add(lessFile);
}
}
return imports;
}
public void compile(@NotNull final LessEngine engine, final LessProfile lessProfile) throws IOException, LessException {
cssChanged.set(false);
final File cssTempFile = createCssTempFile();
LOG.info("Compiling " + getName() + ":\n" +
"\t" + "lessPath: " + getCanonicalPath() + "\n" +
"\t" + "cssTempPath: " + cssTempFile.getCanonicalPath());
final String inputLessCode = FileUtil.loadFile(this, "UTF-8");
final boolean compressOutput = (lessProfile.isCompressOutput() && !inputLessCode.contains("//simpless:!minify")) || inputLessCode.contains("//simpless:minify");
final String compiled = engine.compile(inputLessCode, this.toURI().toURL().toString(), compressOutput);
FileUtil.writeToFile(cssTempFile, compiled);
updateCssFiles(cssTempFile, lessProfile);
}
/*
* Private instance methods
*/
private Set<LessFile> getDependentsRecursive(@NotNull final LessProfile lessProfile,
@NotNull final Set<LessFile> curDependents) throws IOException {
final Set<LessFile> newDependents = new LinkedHashSet<LessFile>();
final Set<LessFile> sourceFileDependents = getFirstLevelDependents(lessProfile);
final Set<LessFile> sourceFileDependentsFiltered = filter(sourceFileDependents, new Filter() {
@Override
public boolean accept(@NotNull final LessFile lessFile) throws IOException {
return !curDependents.contains(lessFile) && !newDependents.contains(lessFile);
}
});
for (final LessFile dependentLessFile : sourceFileDependentsFiltered) {
newDependents.add(dependentLessFile);
final Set<LessFile> allDependents = mergeSets(curDependents, newDependents);
final Set<LessFile> recursiveDependents = dependentLessFile.getDependentsRecursive(lessProfile, allDependents);
newDependents.addAll(recursiveDependents);
}
return newDependents;
}
/**
* Copies the contents of the temp file to its corresponding CSS file(s) in every output directory specified in the profile.
* @throws IOException
*/
private void updateCssFiles(@NotNull final File cssTempFile, @NotNull final LessProfile lessProfile) throws IOException {
if (cssTempFile.length() == 0) {
FileUtil.delete(cssTempFile);
return;
}
final File lessProfileDir = lessProfile.getLessDir();
final String relativeLessPath = StringUtils.defaultString(FileUtil.getRelativePath(lessProfileDir, this));
final String relativeCssPath = relativeLessPath.replaceFirst("\\.less$", ".css");
final String cssTempFileContent = FileUtil.loadFile(cssTempFile);
int numUpdated = 0;
for (final CssDirectory cssDirectory : lessProfile.getCssDirectories()) {
final File cssDestFile = new File(cssDirectory.getPath(), relativeCssPath);
// CSS file hasn't changed, so don't bother updating
if (cssDestFile.exists() && FileUtil.loadFile(cssDestFile).equals(cssTempFileContent))
continue;
numUpdated++;
FileUtil.createIfDoesntExist(cssDestFile);
FileUtil.copy(cssTempFile, cssDestFile);
refreshCssFile(cssDirectory, cssDestFile);
}
FileUtil.delete(cssTempFile);
cssChanged.set(numUpdated > 0);
}
private void refreshCssFile(final CssDirectory cssDirectory, final File cssDestFile) {
ApplicationManager.getApplication().invokeLater(new Runnable() {
@Override
public void run() {
final LocalFileSystem localFileSystem = LocalFileSystem.getInstance();
localFileSystem.refresh(false);
localFileSystem.refreshAndFindFileByPath(cssDestFile.getAbsolutePath());
final VirtualFile virtualCssRoot = localFileSystem.findFileByIoFile(new File(cssDirectory.getPath()));
if (virtualCssRoot != null)
virtualCssRoot.refresh(false, true);
final VirtualFile virtualCssFile = localFileSystem.findFileByIoFile(cssDestFile);
if (virtualCssFile != null)
virtualCssFile.refresh(false, false);
}
});
}
/*
* Public static methods
*/
public static boolean isLessFile(@NotNull final String filename) {
return filename.endsWith(".less");
}
public static boolean isSameFile(@Nullable final LessFile file1, @Nullable final LessFile file2) {
return file1 != null &&
file2 != null &&
file1.getCanonicalPathSafe().equals(file2.getCanonicalPathSafe());
}
/*
* Private static methods
*/
private static List<String> normalizePatterns(final String patterns) {
final Set<String> normalized = new LinkedHashSet<String>();
for (final String pattern : StringUtils.defaultString(patterns).split(";")) {
if (StringUtils.isNotEmpty(pattern))
normalized.add(makePatternAbsolute(pattern));
}
return new ArrayList<String>(normalized);
}
private static String makePatternAbsolute(final String pattern) {
return FileUtil.isAbsolute(pattern) ? pattern : "*" + File.separator + pattern;
}
/**
* Resolves extensionless {@code @import} LESS filenames so that they always end with ".less".
* <p>Examples:</p>
* <pre>resolveImportFileName("main") = "main.less"</pre>
* <pre>resolveImportFileName("main.less") = "main.less"</pre>
* @param filename filename from a LESS {@code @import} that may or may not end with ".less"
* @return the filename ending with ".less"
*/
private static String resolveImportFileName(@Nullable String filename) {
filename = StringUtils.defaultString(filename);
if (filename.endsWith(".less"))
return filename;
else
return filename + ".less";
}
/**
* Returns a set of all LESS files in the profile directory or one of its subdirectories (recursive)
* that match the given filter criteria.
* @param lessProfile LESS profile
* @param filter filter to reduce the result set
* @return all LESS files in the profile directory (recursive)
* @throws IOException
*/
public static Set<LessFile> getLessFiles(@NotNull final LessProfile lessProfile,
@NotNull final Filter filter) throws IOException {
final List<File> files = FileUtil.findFilesByMask(LESS_FILENAME_PATTERN, lessProfile.getLessDir());
final Set<LessFile> lessFiles = new LinkedHashSet<LessFile>();
for (final File file : files) {
final LessFile lessFile = new LessFile(file);
if (filter.accept(lessFile)) {
lessFiles.add(lessFile);
}
}
return lessFiles;
}
private static Set<LessFile> mergeSets(@NotNull final Set<LessFile> paths1, @NotNull final Set<LessFile> paths2) {
final Set<LessFile> mergedPaths = new LinkedHashSet<LessFile>();
mergedPaths.addAll(paths1);
mergedPaths.addAll(paths2);
return mergedPaths;
}
private static Set<LessFile> filter(@NotNull final Set<LessFile> lessFiles, @NotNull final Filter filter) throws IOException {
final Set<LessFile> filtered = new LinkedHashSet<LessFile>();
for (final LessFile lessFile : lessFiles) {
if (filter.accept(lessFile)) {
filtered.add(lessFile);
}
}
return filtered;
}
/**
* Similar to {@link #getCanonicalPath()}, but falls back to returning {@link #getAbsolutePath()} instead of throwing an {@link IOException}.
* @return the canonical path to the {@code File} if it exists; otherwise the absolute path
*/
private static String getCanonicalPathSafe(final File file) {
try {
return file.getCanonicalPath();
} catch (final IOException ignored) {
return file.getAbsolutePath();
}
}
private static File createCssTempFile() throws IOException {
return FileUtil.createTempFile("intellij-lessc-plugin.", ".css", true);
}
/*
* Public inner classes
*/
public static interface Filter {
public boolean accept(@NotNull LessFile lessFile) throws IOException;
}
}