Skip to content

Commit 5e7393c

Browse files
Add ordering to SmithyBuildPlugin
This adds the concept of dependencies to build plugins. Plugins can now declare that they must be run before or after some plugins. This uses the same dependency graph that is used by SmithyIntegration, but unlike integrations there is no concept of a "priority". This is because we may want to be able to run the plugins themselves in parallell, and such a priority would complicate that.
1 parent 0a992d7 commit 5e7393c

File tree

11 files changed

+341
-3
lines changed

11 files changed

+341
-3
lines changed

smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildImpl.java

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import software.amazon.smithy.model.node.ObjectNode;
3434
import software.amazon.smithy.model.transform.ModelTransformer;
3535
import software.amazon.smithy.model.validation.ValidatedResult;
36+
import software.amazon.smithy.utils.DependencyGraph;
3637
import software.amazon.smithy.utils.Pair;
3738
import software.amazon.smithy.utils.SmithyBuilder;
3839

@@ -214,7 +215,8 @@ void applyAllProjections(
214215
private List<ResolvedPlugin> resolvePlugins(String projectionName, ProjectionConfig config) {
215216
// Ensure that no two plugins use the same artifact name.
216217
Set<String> seenArtifactNames = new HashSet<>();
217-
List<ResolvedPlugin> resolvedPlugins = new ArrayList<>();
218+
DependencyGraph<PluginId> dependencyGraph = new DependencyGraph<>();
219+
Map<PluginId, ResolvedPlugin> resolvedPlugins = new HashMap<>();
218220

219221
for (Map.Entry<String, ObjectNode> pluginEntry : getCombinedPlugins(config).entrySet()) {
220222
PluginId id = PluginId.from(pluginEntry.getKey());
@@ -224,12 +226,45 @@ private List<ResolvedPlugin> resolvePlugins(String projectionName, ProjectionCon
224226
id.getArtifactName(),
225227
projectionName));
226228
}
229+
227230
createPlugin(projectionName, id).ifPresent(plugin -> {
228-
resolvedPlugins.add(new ResolvedPlugin(id, plugin, pluginEntry.getValue()));
231+
dependencyGraph.add(id);
232+
for (String dependency : plugin.runAfter()) {
233+
dependencyGraph.addDependency(id, PluginId.from(dependency));
234+
}
235+
for (String dependant : plugin.runBefore()) {
236+
dependencyGraph.addDependency(PluginId.from(dependant), id);
237+
}
238+
resolvedPlugins.put(id, new ResolvedPlugin(id, plugin, pluginEntry.getValue()));
229239
});
230240
}
231241

232-
return resolvedPlugins;
242+
List<PluginId> sorted;
243+
try {
244+
sorted = dependencyGraph.toSortedList();
245+
} catch (IllegalStateException e) {
246+
throw new SmithyBuildException(e.getMessage(), e);
247+
}
248+
249+
List<ResolvedPlugin> result = new ArrayList<>();
250+
for (PluginId id : sorted) {
251+
ResolvedPlugin resolvedPlugin = resolvedPlugins.get(id);
252+
if (resolvedPlugin != null) {
253+
result.add(resolvedPlugin);
254+
continue;
255+
}
256+
257+
// If the plugin wasn't resolved, that's either because it was declared but not
258+
// available on the classpath or not declared at all. In the former case we
259+
// already have a log message that covers it, including a default build failure.
260+
// If the plugin was seen, it was declared, and we don't need to log a second
261+
// time.
262+
if (!seenArtifactNames.contains(id.getArtifactName())) {
263+
logMissingPluginDependency(dependencyGraph, id);
264+
}
265+
}
266+
267+
return result;
233268
}
234269

235270
private Map<String, ObjectNode> getCombinedPlugins(ProjectionConfig projection) {
@@ -257,6 +292,33 @@ private Optional<SmithyBuildPlugin> createPlugin(String projectionName, PluginId
257292
throw new SmithyBuildException(message);
258293
}
259294

295+
private void logMissingPluginDependency(DependencyGraph<PluginId> dependencyGraph, PluginId name) {
296+
StringBuilder message = new StringBuilder("Could not find plugin named '");
297+
message.append(name).append('\'');
298+
if (!dependencyGraph.getDirectDependants(name).isEmpty()) {
299+
message.append(" that was supposed to run before plugins [");
300+
message.append(dependencyGraph.getDirectDependants(name)
301+
.stream()
302+
.map(PluginId::toString)
303+
.collect(Collectors.joining(", ")));
304+
message.append("]");
305+
}
306+
if (!dependencyGraph.getDirectDependencies(name).isEmpty()) {
307+
if (!dependencyGraph.getDirectDependants(name).isEmpty()) {
308+
message.append(" and ");
309+
} else {
310+
message.append(" that ");
311+
}
312+
message.append("was supposed to run after plugins [");
313+
message.append(dependencyGraph.getDirectDependencies(name)
314+
.stream()
315+
.map(PluginId::toString)
316+
.collect(Collectors.joining(", ")));
317+
message.append("]");
318+
}
319+
LOGGER.warning(message.toString());
320+
}
321+
260322
private boolean areAnyResolvedPluginsSerial(List<ResolvedPlugin> resolvedPlugins) {
261323
for (ResolvedPlugin plugin : resolvedPlugins) {
262324
if (plugin.plugin.isSerial()) {

smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildPlugin.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package software.amazon.smithy.build;
66

77
import java.util.ArrayList;
8+
import java.util.Collections;
89
import java.util.List;
910
import java.util.Optional;
1011
import java.util.ServiceLoader;
@@ -104,4 +105,28 @@ static Function<String, Optional<SmithyBuildPlugin>> createServiceFactory() {
104105
static Function<String, Optional<SmithyBuildPlugin>> createServiceFactory(ClassLoader classLoader) {
105106
return createServiceFactory(ServiceLoader.load(SmithyBuildPlugin.class, classLoader));
106107
}
108+
109+
/**
110+
* Gets the names of plugins that this plugin must come before.
111+
*
112+
* <p>Dependencies are soft. Dependencies on plugin names that cannot be found
113+
* log a warning and are ignored.
114+
*
115+
* @return Returns the plugin names this must come before.
116+
*/
117+
default List<String> runBefore() {
118+
return Collections.emptyList();
119+
}
120+
121+
/**
122+
* Gets the name of the plugins that this plugin must come after.
123+
*
124+
* <p>Dependencies are soft. Dependencies on plugin names that cannot be found
125+
* log a warning and are ignored.
126+
*
127+
* @return Returns the plugins names this must come after.
128+
*/
129+
default List<String> runAfter() {
130+
return Collections.emptyList();
131+
}
107132
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.build;
6+
7+
import java.util.List;
8+
import software.amazon.smithy.utils.ListUtils;
9+
10+
public class CyclicPlugin1 implements SmithyBuildPlugin {
11+
@Override
12+
public String getName() {
13+
return "cyclicplugin1";
14+
}
15+
16+
@Override
17+
public void execute(PluginContext context) {}
18+
19+
@Override
20+
public List<String> runBefore() {
21+
return ListUtils.of("cyclicplugin2");
22+
}
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.build;
6+
7+
import java.util.List;
8+
import software.amazon.smithy.utils.ListUtils;
9+
10+
public class CyclicPlugin2 implements SmithyBuildPlugin {
11+
@Override
12+
public String getName() {
13+
return "cyclicplugin2";
14+
}
15+
16+
@Override
17+
public void execute(PluginContext context) {}
18+
19+
@Override
20+
public List<String> runBefore() {
21+
return ListUtils.of("cyclicplugin1");
22+
}
23+
}

smithy-build/src/test/java/software/amazon/smithy/build/SmithyBuildTest.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import static org.hamcrest.Matchers.is;
1414
import static org.hamcrest.Matchers.not;
1515
import static org.junit.jupiter.api.Assertions.assertFalse;
16+
import static org.junit.jupiter.api.Assertions.assertThrows;
1617
import static org.junit.jupiter.api.Assertions.assertTrue;
1718

1819
import java.io.File;
@@ -23,6 +24,7 @@
2324
import java.nio.file.Files;
2425
import java.nio.file.Path;
2526
import java.nio.file.Paths;
27+
import java.time.Instant;
2628
import java.util.ArrayList;
2729
import java.util.Comparator;
2830
import java.util.HashMap;
@@ -539,6 +541,92 @@ private void assertPluginPresent(String pluginName, String outputFileName, Proje
539541
}
540542
}
541543

544+
@Test
545+
public void topologicallySortsPlugins() throws Exception {
546+
Map<String, SmithyBuildPlugin> plugins = MapUtils.of(
547+
"timestampPlugin1",
548+
new TimestampPlugin1(),
549+
"timestampPlugin2",
550+
new TimestampPlugin2(),
551+
"timestampPlugin3",
552+
new TimestampPlugin3());
553+
Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
554+
Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
555+
Optional.ofNullable(plugins.get(name)),
556+
() -> factory.apply(name));
557+
558+
SmithyBuild builder = new SmithyBuild().pluginFactory(composed);
559+
builder.fileManifestFactory(MockManifest::new);
560+
builder.config(SmithyBuildConfig.builder()
561+
.load(Paths.get(getClass().getResource("topologically-sorts-plugins.json").toURI()))
562+
.outputDirectory("/foo")
563+
.build());
564+
565+
SmithyBuildResult results = builder.build();
566+
ProjectionResult source = results.getProjectionResult("source").get();
567+
MockManifest manifest1 = (MockManifest) source.getPluginManifest("timestampPlugin1").get();
568+
MockManifest manifest2 = (MockManifest) source.getPluginManifest("timestampPlugin2").get();
569+
MockManifest manifest3 = (MockManifest) source.getPluginManifest("timestampPlugin3").get();
570+
571+
Instant instant1 = Instant.ofEpochMilli(Long.parseLong(manifest1.getFileString("timestamp").get()));
572+
Instant instant2 = Instant.ofEpochMilli(Long.parseLong(manifest2.getFileString("timestamp").get()));
573+
Instant instant3 = Instant.ofEpochMilli(Long.parseLong(manifest3.getFileString("timestamp").get()));
574+
575+
assertTrue(instant2.isBefore(instant1));
576+
assertTrue(instant3.isAfter(instant1));
577+
}
578+
579+
@Test
580+
public void dependenciesAreSoft() throws Exception {
581+
Map<String, SmithyBuildPlugin> plugins = MapUtils.of(
582+
"timestampPlugin2",
583+
new TimestampPlugin2(),
584+
"timestampPlugin3",
585+
new TimestampPlugin3());
586+
Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
587+
Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
588+
Optional.ofNullable(plugins.get(name)),
589+
() -> factory.apply(name));
590+
591+
SmithyBuild builder = new SmithyBuild().pluginFactory(composed);
592+
builder.fileManifestFactory(MockManifest::new);
593+
builder.config(SmithyBuildConfig.builder()
594+
.load(Paths.get(getClass().getResource("soft-plugin-dependencies.json").toURI()))
595+
.outputDirectory("/foo")
596+
.build());
597+
598+
SmithyBuildResult results = builder.build();
599+
ProjectionResult source = results.getProjectionResult("source").get();
600+
MockManifest manifest2 = (MockManifest) source.getPluginManifest("timestampPlugin2").get();
601+
MockManifest manifest3 = (MockManifest) source.getPluginManifest("timestampPlugin3").get();
602+
603+
assertTrue(manifest2.hasFile("timestamp"));
604+
assertTrue(manifest3.hasFile("timestamp"));
605+
}
606+
607+
@Test
608+
public void detectsPluginCycles() throws Exception {
609+
Map<String, SmithyBuildPlugin> plugins = MapUtils.of(
610+
"cyclicplugin1",
611+
new CyclicPlugin1(),
612+
"cyclicplugin2",
613+
new CyclicPlugin2());
614+
Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
615+
Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
616+
Optional.ofNullable(plugins.get(name)),
617+
() -> factory.apply(name));
618+
619+
SmithyBuild builder = new SmithyBuild().pluginFactory(composed);
620+
builder.fileManifestFactory(MockManifest::new);
621+
builder.config(SmithyBuildConfig.builder()
622+
.load(Paths.get(getClass().getResource("detects-plugin-cycles.json").toURI()))
623+
.outputDirectory("/foo")
624+
.build());
625+
626+
SmithyBuildException e = assertThrows(SmithyBuildException.class, builder::build);
627+
assertThat(e.getMessage(), containsString("Cycle(s) detected in dependency graph"));
628+
}
629+
542630
@Test
543631
public void buildCanOverrideConfigOutputDirectory() throws Exception {
544632
Path outputDirectory = Paths.get("/custom/foo");
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.build;
6+
7+
import java.time.Instant;
8+
9+
public class TimestampPlugin1 implements SmithyBuildPlugin {
10+
@Override
11+
public String getName() {
12+
return "timestampPlugin1";
13+
}
14+
15+
@Override
16+
public void execute(PluginContext context) {
17+
context.getFileManifest().writeFile("timestamp", String.valueOf(Instant.now().toEpochMilli()));
18+
try {
19+
Thread.sleep(1);
20+
} catch (InterruptedException ignored) {}
21+
}
22+
23+
@Override
24+
public boolean isSerial() {
25+
return true;
26+
}
27+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.build;
6+
7+
import java.time.Instant;
8+
import java.util.List;
9+
import software.amazon.smithy.utils.ListUtils;
10+
11+
public class TimestampPlugin2 implements SmithyBuildPlugin {
12+
@Override
13+
public String getName() {
14+
return "timestampPlugin2";
15+
}
16+
17+
@Override
18+
public void execute(PluginContext context) {
19+
context.getFileManifest().writeFile("timestamp", String.valueOf(Instant.now().toEpochMilli()));
20+
try {
21+
Thread.sleep(1);
22+
} catch (InterruptedException ignored) {}
23+
}
24+
25+
@Override
26+
public List<String> runBefore() {
27+
return ListUtils.of("timestampPlugin1");
28+
}
29+
30+
@Override
31+
public boolean isSerial() {
32+
return true;
33+
}
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.build;
6+
7+
import java.time.Instant;
8+
import java.util.List;
9+
import software.amazon.smithy.utils.ListUtils;
10+
11+
public class TimestampPlugin3 implements SmithyBuildPlugin {
12+
@Override
13+
public String getName() {
14+
return "timestampPlugin3";
15+
}
16+
17+
@Override
18+
public void execute(PluginContext context) {
19+
context.getFileManifest().writeFile("timestamp", String.valueOf(Instant.now().toEpochMilli()));
20+
try {
21+
Thread.sleep(1);
22+
} catch (InterruptedException ignored) {}
23+
}
24+
25+
@Override
26+
public List<String> runAfter() {
27+
return ListUtils.of("timestampPlugin1");
28+
}
29+
30+
@Override
31+
public boolean isSerial() {
32+
return true;
33+
}
34+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"version": "2.0",
3+
"plugins": {
4+
"cyclicplugin1": {},
5+
"cyclicplugin2": {}
6+
}
7+
}

0 commit comments

Comments
 (0)