Bug 576888 - Consider included child-features for feature-launches

Change-Id: I32789dba25c16805085c34a52e633222046e0380
Signed-off-by: Hannes Wellmann <wellmann.hannes1@gmx.net>
Reviewed-on: https://git.eclipse.org/r/c/pde/eclipse.pde.ui/+/186946
Tested-by: PDE Bot <pde-bot@eclipse.org>
diff --git a/ui/org.eclipse.pde.launching/src/org/eclipse/pde/internal/launching/launcher/BundleLauncherHelper.java b/ui/org.eclipse.pde.launching/src/org/eclipse/pde/internal/launching/launcher/BundleLauncherHelper.java
index 7eaa6ef..a34419c 100644
--- a/ui/org.eclipse.pde.launching/src/org/eclipse/pde/internal/launching/launcher/BundleLauncherHelper.java
+++ b/ui/org.eclipse.pde.launching/src/org/eclipse/pde/internal/launching/launcher/BundleLauncherHelper.java
@@ -15,6 +15,7 @@
  *     Hannes Wellmann - Bug 577118 - Handle multiple Plug-in versions in launching facility
  *     Hannes Wellmann - Bug 576886 - Clean up and improve BundleLaunchHelper and extract String literal constants
  *     Hannes Wellmann - Bug 576887 - Handle multiple versions of features and plug-ins for feature-launches
+ *     Hannes Wellmann - Bug 576888 - Consider included child-features for feature-launches
  *******************************************************************************/
 package org.eclipse.pde.internal.launching.launcher;
 
@@ -171,15 +172,25 @@
 		Set<String> selectedFeatures = configuration.getAttribute(IPDELauncherConstants.SELECTED_FEATURES, emptySet());
 
 		Map<IFeature, String> feature2pluginResolution = new HashMap<>();
+		Queue<IFeature> pendingFeatures = new ArrayDeque<>();
 		for (String currentSelected : selectedFeatures) {
 			String[] attributes = currentSelected.split(FEATURE_PLUGIN_RESOLUTION_SEPARATOR);
 			if (attributes.length > 1) {
 				String id = attributes[0];
 				String pluginResolution = attributes[1];
 				IFeature feature = getFeature(id, featureMaps);
-				if (feature != null) {
-					feature2pluginResolution.put(feature, pluginResolution);
-				}
+				addFeatureIfAbsent(feature, pluginResolution, feature2pluginResolution, pendingFeatures); // feature should be absent
+			}
+		}
+
+		while (!pendingFeatures.isEmpty()) { // perform exhaustive breath-first-search for included features
+			IFeature feature = pendingFeatures.remove();
+			String pluginResolution = feature2pluginResolution.get(feature); // inherit resolution from including feature
+
+			IFeatureChild[] includedFeatures = feature.getIncludedFeatures();
+			for (IFeatureChild featureChild : includedFeatures) {
+				IFeature child = getIncludedFeature(featureChild.getId(), featureChild.getVersion(), featureMaps);
+				addFeatureIfAbsent(child, pluginResolution, feature2pluginResolution, pendingFeatures);
 			}
 		}
 		return feature2pluginResolution;
@@ -204,6 +215,13 @@
 		return getMaxElement(features, f -> true, comparing(f -> Version.parseVersion(f.getVersion())));
 	}
 
+	private static void addFeatureIfAbsent(IFeature feature, String resolution, Map<IFeature, String> featurePluginResolution, Queue<IFeature> pendingFeatures) {
+		if (feature != null && featurePluginResolution.putIfAbsent(feature, resolution) == null) {
+			// Don't add feature more than once to not override the resolution if already present (e.g. a child was specified explicitly)
+			pendingFeatures.add(feature); // ... and to not process it more than once 
+		}
+	}
+
 	private static boolean isWorkspace(String location) {
 		if (IPDELauncherConstants.LOCATION_WORKSPACE.equalsIgnoreCase(location)) {
 			return true;
@@ -213,6 +231,13 @@
 		throw new IllegalArgumentException("Unsupported location: " + location); //$NON-NLS-1$
 	}
 
+	private static final Comparator<IFeature> NEUTRAL_COMPARATOR = comparing(f -> 0);
+
+	private static IFeature getIncludedFeature(String id, String version, Map<String, List<List<IFeature>>> prioritizedFeatures) {
+		List<List<IFeature>> features = prioritizedFeatures.get(id);
+		return getIncluded(features, f -> true, IFeature::getVersion, NEUTRAL_COMPARATOR, version);
+	}
+
 	private static final Predicate<IPluginModelBase> ENABLED_VALID_PLUGIN_FILTER = p -> p.getBundleDescription() != null && p.isEnabled();
 	private static final Function<IPluginModelBase, String> GET_PLUGIN_VERSION = m -> m.getPluginBase().getVersion();
 	private static final Comparator<IPluginModelBase> COMPARE_PLUGIN_RESOLVED = comparing(p -> p.getBundleDescription().isResolved());
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java
index a31b815..20d3a8d 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java
@@ -393,6 +393,184 @@
 		}
 	}
 
+	// --- included features ---
+
+	@Test
+	public void testGetMergedBundleMap_includedFeatureWithDefaultVersion() throws Throwable {
+		// plug-in names contain version-like suffixes to have no chance to
+		// interfere with conveniences of plug-in resolution
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a.e.100", "1.0.0"), //
+				bundle("plugin.a.w.101", "1.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		createFeatureProject("feature.a", "1.0.3", f -> {
+			addIncludedPlugin(f, "plugin.a.w.101", "1.0.0");
+		});
+		createFeatureProject("feature.a", "1.0.0", f -> {
+			addIncludedPlugin(f, "plugin.z", "1.0.0");
+		});
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.z", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.a", DEFAULT_VERSION);
+				}), //
+
+				targetFeature("feature.a", "1.0.2", f -> {
+					addIncludedPlugin(f, "plugin.a.e.100", "1.0.0");
+				}), //
+				targetFeature("feature.a", "1.0.1", f -> {
+					addIncludedPlugin(f, "plugin.z", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		{
+			ILaunchConfigurationWorkingCopy lcW = createFeatureLaunchConfig();
+			lcW.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.z:default"));
+			lcW.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+			assertGetMergedBundleMap("feature-location workspace", lcW, Set.of( //
+					targetBundle("plugin.a.w.101", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lcE = createFeatureLaunchConfig();
+			lcE.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.z:default"));
+			lcE.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+			assertGetMergedBundleMap("feature-location external", lcE, Set.of( //
+					targetBundle("plugin.a.e.100", "1.0.0")));
+		}
+	}
+
+	@Test
+	public void testGetMergedBundleMap_includedFeatureWithSpecificVersion() throws Throwable {
+		// plug-in names contain version-like suffixes to have no chance to
+		// interfere with conveniences of plug-in resolution
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a.w.100", "1.0.0"), //
+				bundle("plugin.a.w.300", "1.0.0"), //
+				bundle("plugin.a.e.100", "1.0.0"), //
+				bundle("plugin.a.e.200", "1.0.0"), //
+				bundle("plugin.a.e.400", "1.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		createFeatureProject("feature.a", "1.0.0", f -> {
+			addIncludedPlugin(f, "plugin.a.w.100", "1.0.0");
+		});
+		createFeatureProject("feature.a", "3.0.0", f -> {
+			addIncludedPlugin(f, "plugin.a.w.300", "1.0.0");
+		});
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a.e.100", "1.0.0");
+				}), //
+				targetFeature("feature.a", "2.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a.e.200", "1.0.0");
+				}), //
+				targetFeature("feature.a", "4.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a.e.400", "1.0.0");
+				}), //
+
+				targetFeature("feature.b", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.a", "1.0.0");
+				}), //
+				targetFeature("feature.c", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.a", "1.2.0");
+				}), //
+				targetFeature("feature.d", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.a", "2.0.0");
+				}), //
+				targetFeature("feature.e", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.a", "3.0.0");
+				}), //
+				targetFeature("feature.f", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.a", "1.0.0.someQualifier");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		// Perfect version-match in primary location (while secondary location
+		// has matches too)
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.b:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+			assertGetMergedBundleMap("feature-location workspace", lc, Set.of( //
+					targetBundle("plugin.a.w.100", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.b:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+			assertGetMergedBundleMap("feature-location external", lc, Set.of( //
+					targetBundle("plugin.a.e.100", "1.0.0")));
+		}
+		// Unqualified version-match in primary location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.f:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+			assertGetMergedBundleMap("feature-location workspace", lc, Set.of( //
+					targetBundle("plugin.a.w.100", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.f:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+			assertGetMergedBundleMap("feature-location external", lc, Set.of( //
+					targetBundle("plugin.a.e.100", "1.0.0")));
+		}
+		// no version-match at all (for included features the latest features of
+		// a location is added if there is no match in the primary location)
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+			assertGetMergedBundleMap("feature-location workspace no match", lc, Set.of( //
+					targetBundle("plugin.a.w.300", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+			assertGetMergedBundleMap("feature-location external no match", lc, Set.of( //
+					targetBundle("plugin.a.e.400", "1.0.0")));
+		}
+		// Perfect version match only in secondary location (but another version
+		// is present in the primary too).
+		// To be able to conveniently override a specific version of a feature,
+		// which is actually included (transitively) by a feature from the TP,
+		// by a feature from the workspace (which happens frequently when
+		// one has just updated the feature version) the latest feature from the
+		// primary location is taken if one is present and none matches the
+		// specified version exactly (with or without considering qualifiers).
+		// See also https://bugs.eclipse.org/bugs/show_bug.cgi?id=576887
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.d:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+			assertGetMergedBundleMap("feature-location workspace but exact match is external", lc, Set.of( //
+					targetBundle("plugin.a.w.300", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.e:default"));
+			lc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+			assertGetMergedBundleMap("feature-location external but exact match is in workspace", lc, Set.of(//
+					targetBundle("plugin.a.e.400", "1.0.0")));
+		}
+	}
+
 	// --- required/imported plug-in dependencies ---
 
 	@Test
@@ -659,7 +837,8 @@
 				"feature.d:default"));
 
 		assertGetMergedBundleMap(wc, Set.of( //
-				targetBundle("plugin.a", "1.0.0")));
+				targetBundle("plugin.a", "1.0.0"), //
+				targetBundle("plugin.z", "1.0.0")));
 	}
 
 	@Test
@@ -717,6 +896,45 @@
 				targetBundle("plugin.f", "2.0.0"), "6:true"));
 	}
 
+	@Test
+	public void testGetMergedBundleMap_inheritanceOfPluginResolution() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.b", "1.0.0");
+		createPluginProject("plugin.d", "1.0.0");
+		createPluginProject("plugin.e", "1.0.0");
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.b", "1.0.0"), //
+				bundle("plugin.c", "1.0.0"), //
+				bundle("plugin.d", "1.0.0"), //
+				bundle("plugin.e", "1.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.b", "1.0.0");
+					addIncludedFeature(f, "feature.d", "1.0.0");
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.b", "1.0.0");
+				}), //
+				targetFeature("feature.d", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.d", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of( //
+				"feature.a:external", //
+				"feature.d:workspace"));
+
+		assertGetMergedBundleMap(wc, Set.of( //
+				targetBundle("plugin.b", "1.0.0"), //
+				workspaceBundle("plugin.d", "1.0.0")));
+	}
+
 	// --- utility methods ---
 
 	private static interface CoreConsumer<E> {
diff --git a/ui/org.eclipse.pde.ui/src/org/eclipse/pde/internal/ui/launcher/LaunchAction.java b/ui/org.eclipse.pde.ui/src/org/eclipse/pde/internal/ui/launcher/LaunchAction.java
index 627e717..d6477c1 100644
--- a/ui/org.eclipse.pde.ui/src/org/eclipse/pde/internal/ui/launcher/LaunchAction.java
+++ b/ui/org.eclipse.pde.ui/src/org/eclipse/pde/internal/ui/launcher/LaunchAction.java
@@ -134,7 +134,9 @@
 	}
 
 	private void refreshFeatureLaunchAttributes(ILaunchConfigurationWorkingCopy wc, IPluginModelBase[] models) {
-		Set<String> selectedFeatures = Arrays.stream(getUniqueFeatures())
+		FeatureModelManager fmm = PDECore.getDefault().getFeatureModelManager();
+		Set<String> selectedFeatures = Arrays.stream(fProduct.getFeatures()) //
+				.map(f -> fmm.findFeatureModel(f.getId(), f.getVersion())).filter(Objects::nonNull)
 				.map(m -> formatFeatureEntry(m.getFeature().getId(), IPDELauncherConstants.LOCATION_DEFAULT))
 				.collect(Collectors.toCollection(LinkedHashSet::new));