package net.bitpot.railways.routesView; import com.intellij.execution.process.ProcessOutput; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.application.TransactionGuard; import com.intellij.openapi.components.PersistentStateComponent; import com.intellij.openapi.components.State; import com.intellij.openapi.components.Storage; import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.module.Module; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.progress.Task; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.vfs.VirtualFile; import net.bitpot.railways.models.RouteList; import net.bitpot.railways.parser.RailsRoutesParser; import net.bitpot.railways.utils.RailwaysUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.plugins.ruby.rails.model.RailsApp; import java.io.File; import java.util.LinkedList; @State( name = "RailwaysModuleConfiguration", storages = {@Storage(file = "$MODULE_FILE$")} ) /** * Class is responsible for receiving and storing the list of routes for * a Rails module. */ public class RoutesManager implements PersistentStateComponent<RoutesManager.State> { /** * Default state. It is set just after RoutesManager is created. */ public static final int DEFAULT = 0; /** * This state is set when RoutesManager is requested routes and awaiting * for result. */ public static final int UPDATING = 1; /** * The state is set when routes were successfully parsed and RouteList * created. */ public static final int UPDATED = 2; /** * This state is set when routes couldn't be retrieved by some reasons */ public static final int ERROR = 3; private int myRoutesState = DEFAULT; private LinkedList<RoutesManagerListener> listeners = new LinkedList<RoutesManagerListener>(); // This field is set only when route update task is being executed. private ProgressIndicator routesUpdateIndicator = null; private ProcessOutput output; private RailsRoutesParser parser; private RouteList routeList = new RouteList(); // Rails module private Module module = null; private State myModuleSettings = new State(); public static class State { // Name of rake task which retrieves routes. public String routesTaskName = "routes"; // Environment which is used to run rake task. public String environment = null; // Automatically update routes when routes.rb file is changed. public Boolean autoUpdate = false; // Check whether route action is found in the project and highlight // actions in route list depending on their availability. public boolean liveActionHighlighting = true; } /** * Constructor of RoutesManager. * * @param railsModule Rails module which routes will be served by * RoutesManager. Specified module should be a Rails * application module. */ public RoutesManager(Module railsModule) { parser = new RailsRoutesParser(railsModule); module = railsModule; } /** * Returns current state of RouteManager. * @return Current state. */ public int getRoutesState() { return myRoutesState; } /** * Returns plugin module settings. * @return ModuleSettings instance. */ @NotNull @Override public State getState() { return myModuleSettings; } /** * This method is called when new component state is loaded. A component should expect this method * to be called at any moment of its lifecycle. The method can and will be called several times, if * config files were externally changed while IDEA running. */ @Override public void loadState(State state) { myModuleSettings = state; } /** * Returns a module which is served by the RoutesManager. * * @return Linked module. */ public Module getModule() { return module; } public void addListener(RoutesManagerListener listener) { listeners.add(listener); } @SuppressWarnings("unused") public boolean removeListener(RoutesManagerListener listener) { return listeners.remove(listener); } /** * Returns current RouteList. Returned value is always valid RouteList * object, but this object is recreated each time routes are updated. * @return Current route list. */ @NotNull public RouteList getRouteList() { return routeList; } /** * Initializes route list. Does nothing if cache is disabled. * When cache is enabled, tries to get routes from cache and if not found * tries to update routes. */ public void initRouteList() { String cachedOutput = getCachedOutput(); if (cachedOutput != null) { parseRakeRoutesOutput(cachedOutput, null); } else updateRouteList(); } /** * Returns true if routes update task is in progress. * * @return True if routes are being updated. */ public boolean isUpdating() { return routesUpdateIndicator != null; } /** * Cancels route update if the task is in progress. */ public void cancelRoutesUpdate() { if (!isUpdating()) return; routesUpdateIndicator.cancel(); } /** * Updates route list. The method starts task that call 'rake routes' and parses result after complete. * After routes are parsed, Routes panel is updated. * * @return True if update task is started, false if new task is not started because routes update is in progress. */ public boolean updateRouteList() { if (isUpdating()) return false; setState(UPDATING); // Save all documents to make sure that requestMethods will be collected using actual files. TransactionGuard.submitTransaction(ApplicationManager.getApplication(), () -> { FileDocumentManager.getInstance().saveAllDocuments(); // Start background task. (new UpdateRoutesTask()).queue(); }); return true; } public int getParserErrorCode() { return parser.getErrorCode(); } /** * Returns ruby exception stack trace from output of executed rake-task. * * @return Error stack trace. */ public String getParseErrorStackTrace() { return parser.getErrorStacktrace(); } /** * Sets new state of RoutesManager and notifies all listeners if the state * was changed. * * @param newState New state of RouteManager. */ private void setState(int newState) { if (myRoutesState == newState) return; myRoutesState = newState; // Notify listeners. for (RoutesManagerListener l : listeners) l.stateChanged(this); } /** * Internal class that is responsible for executing rake task and receiving * its output. */ private class UpdateRoutesTask extends Task.Backgroundable { public UpdateRoutesTask() { super(module.getProject(), "Rake task", true); setCancelText("Cancel task"); } @Override public void run(@NotNull ProgressIndicator indicator) { indicator.setText("Updating route list for module " + getModule().getName() + "..."); indicator.setFraction(0.0); // Save indicator to be able to cancel task execution. routesUpdateIndicator = indicator; output = RailwaysUtils.queryRakeRoutes(getModule(), myModuleSettings.routesTaskName, myModuleSettings.environment); if (output == null) setState(UPDATED); indicator.setFraction(1.0); } @Override public void onSuccess() { routesUpdateIndicator = null; if ((output == null) || (!myProject.isOpen()) || myProject.isDisposed()) return; parseRakeRoutesOutput(output.getStdout(), output.getStderr()); } @Override public void onCancel() { routesUpdateIndicator = null; setState(UPDATED); super.onCancel(); } } /** * Parses 'rake routes' output and notifies all listeners that route list was updated. * * @param stdOut Rake routes result. * @param stdErr Rake routes stderr output. Can be null. */ private void parseRakeRoutesOutput(String stdOut, @Nullable String stdErr) { routeList = parser.parse(stdOut, stdErr); RailwaysUtils.updateActionsStatus(getModule(), routeList); // After routes parsing we can have several situations: // 1. parser contains routes and isErrorReported = false. Everything is OK. // 2. parser contains no routes and isErrorsReported = true. It means that there was an exception thrown. // 3. parser contains routes and isErrorReported = true. In the most cases it's warnings (deprecation etc), // so everything is OK. // TODO: possibly, we should report about warnings somehow. if (routeList.size() == 0 && parser.isErrorReported()) { setState(ERROR); } else { cacheOutput(stdOut); setState(UPDATED); } } /** * Saves passed output string to cache file and sets the same modification time as routes.rb has. * * @param output String that contains stdout of 'rake routes' command. */ private void cacheOutput(String output) { try { // Cache output File f = new File(getCacheFileName()); FileUtil.writeToFile(f, output.getBytes(), false); // Set cache file modification date/time the same as for routes.rb f.setLastModified(getRoutesFileMTime()); } catch (Exception e) { // Do nothing } } /** * Returns modification time of routes.rb file for Rails project. * * @return Modification time of routes.rb file or 0 if it cannot be retrieved. */ private long getRoutesFileMTime() { RailsApp railsApp = RailsApp.fromModule(module); if (railsApp == null || railsApp.getRoutesFile() == null) return 0; String routesRbPath = railsApp.getRoutesFile().getPresentableUrl(); return new File(routesRbPath).lastModified(); } /** * Returns cached output if cache file exists and actual. Cache file is * considered to be actual if its modification time is the same as for * routes.rb file of current project. * * @return String that contains cached output or null if no valid cache * date is found. */ @Nullable private String getCachedOutput() { try { String fileName = getCacheFileName(); File f = new File(fileName); // Check if cached file still contains actual data. Cached file and routes.rb file should have the same // modification time. long routesMTime = getRoutesFileMTime(); if (routesMTime != f.lastModified()) return null; return FileUtil.loadFile(f); } catch (Exception e) { return null; } } /** * Returns name of the cache file which contains output data. * * @return Name of the cache file. */ private String getCacheFileName() { // TODO: check where cache file is placed in IntelliJ IDEA VirtualFile moduleFile = getModule().getModuleFile(); if (moduleFile == null || moduleFile.getParent() == null) return null; return moduleFile.getParent().getPresentableUrl() + File.separator + "railways.cache"; } }