/* * Copyright 2003-2016 JetBrains s.r.o. * * 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 jetbrains.mps.project; import com.intellij.ide.util.gotoByName.ChooseByNamePopup; import com.intellij.ide.util.gotoByName.ChooseByNamePopupComponent.Callback; import com.intellij.openapi.actionSystem.ShortcutSet; import com.intellij.openapi.application.ModalityState; import jetbrains.mps.module.ReloadableModule; import jetbrains.mps.project.structure.modules.ModuleReference; import jetbrains.mps.scope.ConditionalScope; import jetbrains.mps.smodel.BootstrapLanguages; import jetbrains.mps.smodel.Language; import jetbrains.mps.smodel.ModelAccessHelper; import jetbrains.mps.smodel.SLanguageHierarchy; import jetbrains.mps.smodel.SModelOperations; import jetbrains.mps.smodel.adapter.ids.MetaIdHelper; import jetbrains.mps.smodel.adapter.ids.SLanguageId; import jetbrains.mps.smodel.adapter.structure.MetaAdapterFactory; import jetbrains.mps.smodel.language.LanguageRegistry; import jetbrains.mps.util.Reference; import jetbrains.mps.workbench.choose.ChooseByNameData; import jetbrains.mps.workbench.choose.LanguagesPresentation; import jetbrains.mps.workbench.goTo.ui.MpsPopupFactory; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.mps.openapi.language.SLanguage; import org.jetbrains.mps.openapi.model.SModel; import org.jetbrains.mps.openapi.module.SModule; import org.jetbrains.mps.openapi.module.SModuleReference; import org.jetbrains.mps.openapi.module.SRepository; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Set; /** * Facility to interoperate with user to add a new used language to a model/devkit. * Responsible to collect user input and to access model/module internals to update respectively. * <p/> * Since this class shows UI, it takes over responsibility to control model access and executing appropriate commands. * <p/> * Configure the helper first ({@link #setOnCloseActivity(Runnable)}, {@link #setShortcut(ShortcutSet)}, then perform * actual selection and modification with {@link #addUsedLanguage(SModel)} or {@link #addExportedLanguage(DevKit)} * * @author Artem Tikhomirov * @since 3.4 */ public final class LanguageImportHelper { private final MPSProject myProject; private Runnable myOnCloseActivity; private ShortcutSet myShortcut; private Interaction myInteraction; public LanguageImportHelper(@NotNull MPSProject project) { myProject = project; myInteraction = new UiInteraction(); } /** * Construct import helper with a custom interaction, typically non-ui, which is the default. Used for testing. * * @param interaction Custom interaction object */ public LanguageImportHelper(@NotNull MPSProject project, @NotNull Interaction interaction) { myProject = project; myInteraction = interaction; } /** * This is the way it used to be. Its use suggests ChooseByNamePopupComponent.invoke() returns immediately, and we need * a mechanism to perform an action only once dialog to pick a language closes. * <p/> * NOTE, this is configuration method and shall be invoked prior to {@link #addExportedLanguage(DevKit)} or {@link #addUsedLanguage(SModel)} calls * * @param runnable code to execute once language to import has been picked or selection dialog has been closed * @return {@code this} for convenience */ @SuppressWarnings("unused") // in use from idea plugin, MakeDirAModel. public LanguageImportHelper setOnCloseActivity(@Nullable Runnable runnable) { myOnCloseActivity = runnable; return this; } /** * For a dialog to pick a language, one might want to override keyboard shortcut * to switch between global and package scope (e.g. to match that of invoking action). * <p/> * NOTE, this is configuration method and shall be invoked prior to {@link #addExportedLanguage(DevKit)} or {@link #addUsedLanguage(SModel)} calls * * @return {@code this} for convenience */ public LanguageImportHelper setShortcut(@Nullable ShortcutSet shortcut) { myShortcut = shortcut; return this; } /** * Use this method to add a language to set of languages exposed by the devkit. * * @param devkit affected devkit, the one to add new export */ public void addExportedLanguage(@NotNull final DevKit devkit) { chooseLanguage(new jetbrains.mps.util.Callback<SLanguage>() { @Override public void call(final SLanguage param) { final Set<SLanguage> importCandidates = new ModelAccessHelper(myProject.getModelAccess()).runWriteAction(() -> { Set<SLanguage> langs = getExtendedLanguages(param); final Collection<SLanguage> alreadyImported = new HashSet<>(); for (SLanguage language : devkit.getAllExportedLanguageIds()) { alreadyImported.add(language); } langs.removeAll(alreadyImported); return langs; }); final Set<SLanguage> toImport = new HashSet<>(); if (!importCandidates.isEmpty()) { toImport.addAll(chooseModulesToImport(importCandidates)); } /*TODO: rewrite importing: * If module is already imported itself/by devkit either do nothing or show message. * Better try to filter all such imports in the first place. * */ toImport.add(param); myProject.getModelAccess().executeCommand(new Runnable() { @Override public void run() { for (SLanguage li : toImport) { SModuleReference ref = moduleRefForLanguage(li); SModule lang = ref.resolve(myProject.getRepository()); if (lang instanceof Language) { devkit.getModuleDescriptor().getExportedLanguages().add(ref); devkit.setChanged(); } } } // FIXME copied from SModelDescriptorStub#moduleRefForLanguage as there's no way to go backwards from newer concepts private SModuleReference moduleRefForLanguage(SLanguage lang) { String name = lang.getQualifiedName(); SLanguageId id = MetaIdHelper.getLanguage(lang); ModuleId moduleId = ModuleId.regular(id.getIdValue()); return new ModuleReference(name, moduleId); } }); } }); } /** * Use this method to record new used language in a model. * * @param model affected model, the one to get another language to use */ public void addUsedLanguage(@NotNull final SModel model) { chooseLanguage(new jetbrains.mps.util.Callback<SLanguage>() { @Override public void call(final SLanguage param) { final Set<SLanguage> importCandidates = new ModelAccessHelper(myProject.getModelAccess()).runWriteAction(() -> { Set<SLanguage> langs = getExtendedLanguages(param); // XXX likely, all imported + all visible (i.e. those extended) shall be considered - // there's no need to import otherwise visible language final Set<SLanguage> alreadyImported = SModelOperations.getAllLanguageImports(model); langs.removeAll(alreadyImported); return langs; }); final Set<SLanguage> toImport = new HashSet<>(); if (!importCandidates.isEmpty()) { toImport.addAll(chooseModulesToImport(importCandidates)); } /*TODO: rewrite importing: * If module is already imported itself/by devkit either do nothing or show message. * Better try to filter all such imports in the first place. * */ toImport.add(param); myProject.getModelAccess().executeCommand(() -> { boolean reload = false; final boolean reloadableModule = model.getModule() instanceof ReloadableModule; Set<SLanguage> existingUsedLanguages = reloadableModule ? model.getModule().getUsedLanguages() : Collections.<SLanguage>emptySet(); for (SLanguage rtLanguage : toImport) { if (!existingUsedLanguages.contains(rtLanguage)) { // If model gets the new import, then its module would get new dependency reload = true; } ((jetbrains.mps.smodel.SModelInternal) model).addLanguage(rtLanguage); } if (reloadableModule && reload) { ((ReloadableModule) model.getModule()).reload(); } }); } }); } /*package*/ Set<SLanguage> getExtendedLanguages(SLanguage param) { final LanguageRegistry languageRegistry = LanguageRegistry.getInstance(myProject.getRepository()); SLanguageHierarchy langHierarchy = new SLanguageHierarchy(languageRegistry, Collections.singleton(param)); Set<SLanguage> langs = langHierarchy.getExtended(); langs.remove(param); // todo: ! ? //this is added in language implicitly, so we don't show this import langs.remove(BootstrapLanguages.getLangCore()); return langs; } @NotNull /*package*/ Set<SLanguage> chooseModulesToImport(Set<SLanguage> candidates) { return myInteraction.chooseAdditionalLanguages(candidates); } private void chooseLanguage(final jetbrains.mps.util.Callback<SLanguage> addLanguageAction) { final SRepository repo = myProject.getRepository(); final Reference<Collection<SLanguage>> projectScope = new Reference<>(); final Reference<Collection<SLanguage>> globalScope = new Reference<>(); repo.getModelAccess().runReadAction(() -> { ArrayList<SLanguage> projectLanguages = new ArrayList<>(20); for (SModule m : new ConditionalScope(myProject.getScope(), new ModuleInstanceCondition(Language.class), null).getModules()) { assert m instanceof Language; projectLanguages.add(MetaAdapterFactory.getLanguage(m.getModuleReference())); } projectScope.set(projectLanguages); globalScope.set(LanguageRegistry.getInstance(repo).getAllLanguages()); }); ChooseByNameData<SLanguage> gotoData = new ChooseByNameData<>(new LanguagesPresentation()); gotoData.setScope(projectScope.get(), globalScope.get()); gotoData.derivePrompts("language").setPrompts("Import language:", gotoData.getNotFoundMessage(), gotoData.getNotInMessage()); // we used to allow multiple selection, but didn't handle it in any special way // (each selected language would trigger own extra dialog to import extended, which is odd) myInteraction.chooseLanguage(gotoData, new Callback() { private SLanguage myLanguage; /** * Just save chosen element here. * <br> * Language will be added late in {@link #onClose()}. * */ @Override public void elementChosen(Object element) { if (element instanceof SLanguage) { myLanguage = (SLanguage) element; } } /** * Clients expect {@code myOnCloseActivity.run()} will be executed regardless of selection (i.e. when user canceled a dialog). * <br> * This works because of methods call order contract in {@link com.intellij.ide.util.gotoByName.ChooseByNamePopupComponent.Callback}: * {@link Callback#elementChosen(Object)} called before {@link Callback#onClose()} * <p/> * For more information see <a href="https://youtrack.jetbrains.com/issue/IDEA-155319">IDEA-155319</a> * */ @Override public void onClose() { if (myLanguage != null) { addLanguageAction.call(myLanguage); } if (myOnCloseActivity != null) { myOnCloseActivity.run(); } } }); } /** * Instance of this class behaves like a user who uses this import helper. * It answers this import helper's questions as to what language to import and what additional languages to import afterwards. * Interaction via showing dialog windows in a special case. * This class is used to make LanguageImportHelper testable in headless mode. */ public interface Interaction { void chooseLanguage(ChooseByNameData<SLanguage> model, Callback addLanguageAction); Set<SLanguage> chooseAdditionalLanguages(Set<SLanguage> candidates); } private class UiInteraction implements Interaction { @Override public void chooseLanguage(ChooseByNameData<SLanguage> model, Callback addLanguageAction) { final ChooseByNamePopup popup = MpsPopupFactory.createPackagePopup(myProject.getProject(), model, null); if (myShortcut != null) { popup.setCheckBoxShortcut(myShortcut); } // we used to allow multiple selection, but didn't handle it in any special way // (each selected language would trigger own extra dialog to import extended, which is odd) popup.invoke(addLanguageAction, ModalityState.current(), false); } @Override public Set<SLanguage> chooseAdditionalLanguages(Set<SLanguage> candidates) { SelectLanguagesDialog dialog = new SelectLanguagesDialog(myProject.getProject(), candidates); dialog.show(); if (dialog.isOK()) { return dialog.getSelectedModules(); } return Collections.emptySet(); } } }