diff --git a/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy b/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy index 757f5812..dad3da9a 100644 --- a/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy +++ b/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy @@ -24,7 +24,7 @@ Path baseDirectory = basedir.toPath().toAbsolutePath() Path logFile = baseDirectory.resolve("build.log") List logLines = Files.readAllLines(logFile) -int indexOfMatch(List logLines, String pattern) { +static int indexOfMatch(List logLines, String pattern) { for (int i = 0; i < logLines.size(); ++i) { if (logLines.get(i).matches(pattern)) { return i diff --git a/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy b/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy index f045f29a..cb1506dd 100644 --- a/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy +++ b/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy @@ -54,6 +54,8 @@ assertThat(expectedGeneratedFile) // Verify we invoked the JVM with a module path. assertThat(Files.list(expectedScriptsDirectory)) .singleElement(InstanceOfAssertFactories.PATH) + .isDirectory() + .extracting({ dir -> dir.resolve("args.txt") }, InstanceOfAssertFactories.PATH) .isRegularFile() .content() .contains("--module-path") diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java index b5d16e1d..583f3d57 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java @@ -19,10 +19,10 @@ import io.github.ascopes.protobufmavenplugin.dependencies.MavenArtifactPathResolver; import io.github.ascopes.protobufmavenplugin.dependencies.PlatformClassifierFactory; import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; -import io.github.ascopes.protobufmavenplugin.dependencies.SystemPathBinaryResolver; import io.github.ascopes.protobufmavenplugin.dependencies.UrlResourceFetcher; import io.github.ascopes.protobufmavenplugin.utils.Digests; import io.github.ascopes.protobufmavenplugin.utils.FileUtils; +import io.github.ascopes.protobufmavenplugin.utils.SystemPathBinaryResolver; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java index bcd0f4d4..79c02ffd 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java @@ -16,24 +16,30 @@ package io.github.ascopes.protobufmavenplugin.plugins; +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.CREATE_NEW; + import io.github.ascopes.protobufmavenplugin.dependencies.DependencyResolutionDepth; import io.github.ascopes.protobufmavenplugin.dependencies.MavenArtifactPathResolver; import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; import io.github.ascopes.protobufmavenplugin.generation.TemporarySpace; +import io.github.ascopes.protobufmavenplugin.utils.ArgumentFileBuilder; import io.github.ascopes.protobufmavenplugin.utils.Digests; import io.github.ascopes.protobufmavenplugin.utils.FileUtils; import io.github.ascopes.protobufmavenplugin.utils.HostSystem; import io.github.ascopes.protobufmavenplugin.utils.Shlex; +import io.github.ascopes.protobufmavenplugin.utils.SystemPathBinaryResolver; import java.io.IOException; import java.lang.module.ModuleFinder; import java.lang.module.ModuleReference; import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.Set; @@ -46,79 +52,89 @@ import org.slf4j.LoggerFactory; /** - * Wraps a JVM-based plugin invocation using an OS-native script that calls Java. + * Component that takes a reference to a pure-Java {@code protoc} plugin and wraps it in a shell + * script or batch file to invoke it as if it were a single executable binary. + * + *

The aim is to support enabling protoc to invoke executables without creating native + * binaries first, which is error-prone and increases the complexity of this Maven plugin + * significantly. * - *

This script can be marked as executable and passed to the {@code protoc} invocation - * as a path to ensure the script gets called correctly. By doing this, we avoid the need to build - * OS-native executables during the protobuf compilation process. + *

This implementation is a rewrite as of v2.6.0 that now uses Java argument files to + * deal with argument quoting in a platform-agnostic way, since the specification for batch files is + * very poorly documented and full of edge cases that could cause builds to fail. * * @author Ashley Scopes + * @since 2.6.0 */ @Named public final class JvmPluginResolver { private static final Set ALLOWED_SCOPES = Set.of("compile", "runtime", "system"); - private static final Logger log = LoggerFactory.getLogger(BinaryPluginResolver.class); + private static final Logger log = LoggerFactory.getLogger(JvmPluginResolver.class); private final HostSystem hostSystem; private final MavenArtifactPathResolver artifactPathResolver; private final TemporarySpace temporarySpace; + private final SystemPathBinaryResolver pathResolver; @Inject public JvmPluginResolver( HostSystem hostSystem, MavenArtifactPathResolver artifactPathResolver, - TemporarySpace temporarySpace + TemporarySpace temporarySpace, + SystemPathBinaryResolver pathResolver ) { this.hostSystem = hostSystem; this.artifactPathResolver = artifactPathResolver; this.temporarySpace = temporarySpace; + this.pathResolver = pathResolver; } - public Collection resolveMavenPlugins( - Collection plugins + public Collection resolveMavenPlugins( + Collection pluginDescriptors ) throws IOException, ResolutionException { var resolvedPlugins = new ArrayList(); - for (var plugin : plugins) { - if (plugin.isSkip()) { - log.info("Skipping plugin {}", plugin); + for (var pluginDescriptor : pluginDescriptors) { + if (pluginDescriptor.isSkip()) { + log.info("Skipping plugin {}", pluginDescriptor); continue; } - resolvedPlugins.add(resolve(plugin)); + var resolvedPlugin = resolveMavenPlugin(pluginDescriptor); + resolvedPlugins.add(resolvedPlugin); } - return resolvedPlugins; + return Collections.unmodifiableList(resolvedPlugins); } - private ResolvedProtocPlugin resolve( - MavenProtocPlugin plugin + private ResolvedProtocPlugin resolveMavenPlugin( + MavenProtocPlugin pluginDescriptor ) throws IOException, ResolutionException { log.debug( "Resolving JVM-based Maven protoc plugin {} and generating OS-specific boostrap scripts", - plugin + pluginDescriptor ); - var pluginId = pluginIdDigest(plugin); - var argLine = resolveAndBuildArgLine(plugin); + var id = hashPlugin(pluginDescriptor); + var argLine = buildArgLine(pluginDescriptor); + var javaPath = hostSystem.getJavaExecutablePath(); + var scratchDir = temporarySpace.createTemporarySpace("plugins", "jvm", id); var scriptPath = hostSystem.isProbablyWindows() - ? writeWindowsBatchScript(pluginId, argLine) - : writeShellScript(pluginId, argLine); + ? writeWindowsScripts(id, javaPath, scratchDir, argLine) + : writePosixScripts(id, javaPath, scratchDir, argLine); return ImmutableResolvedProtocPlugin .builder() - .id(pluginId) + .id(id) .path(scriptPath) - .options(plugin.getOptions()) - .order(plugin.getOrder()) + .options(pluginDescriptor.getOptions()) + .order(pluginDescriptor.getOrder()) .build(); } - private List resolveAndBuildArgLine( - MavenProtocPlugin plugin - ) throws ResolutionException, IOException { - + private ArgumentFileBuilder buildArgLine(MavenProtocPlugin plugin) + throws ResolutionException, IOException { // Expectation: this always has at least one item in it, and the first item is the plugin // artifact itself. var dependencies = artifactPathResolver @@ -128,10 +144,11 @@ private List resolveAndBuildArgLine( ALLOWED_SCOPES, false, true - ); + ) + .stream() + .collect(Collectors.toUnmodifiableList()); - var args = new ArrayList(); - args.add(hostSystem.getJavaExecutablePath().toString()); + var args = new ArgumentFileBuilder(); // JVM tuning flags to improve the performance of short-lived processes. args.add("-Xshare:auto"); @@ -153,7 +170,7 @@ private List resolveAndBuildArgLine( args.add(determineMainClass(plugin, dependencies.get(0))); - return Collections.unmodifiableList(args); + return args; } private String determineMainClass(MavenProtocPlugin plugin, Path pluginPath) throws IOException { @@ -227,82 +244,81 @@ private String buildJavaPath(Iterable iterable) { return sb.toString(); } - private String pluginIdDigest(MavenProtocPlugin plugin) { - return Digests.sha1(plugin.toString()); + private List findJavaModules(List paths) { + // TODO: is using a module finder here an overkill? + return ModuleFinder.of(paths.toArray(Path[]::new)) + .findAll() + .stream() + .map(ModuleReference::location) + .flatMap(Optional::stream) + .map(Path::of) + .map(FileUtils::normalize) + .peek(modulePath -> log.debug("Looks like {} is a JPMS module!", modulePath)) + // Sort as the order of output is arbitrary, and this ensures reproducible builds. + .sorted(Comparator.comparing(Path::toString)) + .collect(Collectors.toUnmodifiableList()); } - private Path resolvePluginScriptPath() { - return temporarySpace.createTemporarySpace("plugins", "jvm"); - } + private Path writePosixScripts( + String id, + Path javaExecutable, + Path scratchDir, + ArgumentFileBuilder argumentFileBuilder + ) throws IOException, ResolutionException { + var sh = pathResolver.resolve("sh").orElseThrow(); + var argLineFile = writeArgLineFile(id, UTF_8, scratchDir, argumentFileBuilder); + var file = scratchDir.resolve("invoke.sh"); - private Path writeWindowsBatchScript( - String pluginId, - List argLine - ) throws IOException { - var fullScriptPath = resolvePluginScriptPath().resolve(pluginId + ".bat"); - - var script = String.join( - "\r\n", - "@echo off", - "", - ":: ##################################################", - ":: ### Generated by ascopes/protobuf-maven-plugin ###", - ":: ### Users should not invoke this script ###", - ":: ### directly, unless they know what they are ###", - ":: ### doing. ###", - ":: ##################################################", - "", - Shlex.quoteBatchArgs(argLine), - "" // Trailing newline. - ); + try (var writer = Files.newBufferedWriter(file, UTF_8, CREATE_NEW)) { + writer.write("#!"); + writer.write(sh.toString()); + writer.write("\n"); - writeScript(fullScriptPath, script, StandardCharsets.ISO_8859_1); - return fullScriptPath; + writer.write("set -o errexit"); + writer.write("\n"); + + writer.write(Shlex.quoteShellArgs(List.of(javaExecutable.toString(), "@" + argLineFile))); + writer.write("\n"); + } + FileUtils.makeExecutable(file); + + return file; } - private Path writeShellScript( - String pluginId, - List argLine + private Path writeWindowsScripts( + String id, + Path javaExecutable, + Path scratchDir, + ArgumentFileBuilder argumentFileBuilder ) throws IOException { - var fullScriptPath = resolvePluginScriptPath().resolve(pluginId + ".sh"); - - var script = String.join( - "\n", - "#!/usr/bin/env sh", - "", - "##################################################", - "### Generated by ascopes/protobuf-maven-plugin ###", - "### Users should not invoke this script ###", - "### directly unless they know what they are ###", - "### doing. ###", - "##################################################", - "", - "set -eu", - "", - Shlex.quoteShellArgs(argLine), - "" // Trailing newline - ); + var argLineFile = writeArgLineFile(id, ISO_8859_1, scratchDir, argumentFileBuilder); + var file = scratchDir.resolve("invoke.bat"); + + try (var writer = Files.newBufferedWriter(file, ISO_8859_1, CREATE_NEW)) { + writer.write("@echo off"); + writer.write("\r\n"); - writeScript(fullScriptPath, script, StandardCharsets.UTF_8); - return fullScriptPath; + writer.write(Shlex.quoteBatchArgs(List.of(javaExecutable.toString(), "@" + argLineFile))); + writer.write("\r\n"); + } + + return file; } - private void writeScript(Path path, String content, Charset charset) throws IOException { - log.debug("Writing the following script to {} as {}:\n{}", path, charset, content); - Files.writeString(path, content, charset); - FileUtils.makeExecutable(path); + private Path writeArgLineFile( + String id, + Charset charset, + Path scratchDir, + ArgumentFileBuilder argumentFileBuilder + ) throws IOException { + var file = scratchDir.resolve("args.txt"); + try (var writer = Files.newBufferedWriter(file, charset, CREATE_NEW)) { + argumentFileBuilder.write(writer); + } + return file; } - private List findJavaModules(List paths) { - // TODO: is using a module finder here an overkill? - return ModuleFinder.of(paths.toArray(Path[]::new)) - .findAll() - .stream() - .map(ModuleReference::location) - .flatMap(Optional::stream) - .map(Path::of) - .map(FileUtils::normalize) - .peek(modulePath -> log.debug("Looks like {} is a JPMS module!", modulePath)) - .collect(Collectors.toUnmodifiableList()); + private String hashPlugin(MavenProtocPlugin plugin) { + return Digests.sha1(plugin.toString()); } } diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java index 9fe8f393..f9fde9b4 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java @@ -20,10 +20,10 @@ import io.github.ascopes.protobufmavenplugin.dependencies.MavenArtifactPathResolver; import io.github.ascopes.protobufmavenplugin.dependencies.PlatformClassifierFactory; import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; -import io.github.ascopes.protobufmavenplugin.dependencies.SystemPathBinaryResolver; import io.github.ascopes.protobufmavenplugin.dependencies.UrlResourceFetcher; import io.github.ascopes.protobufmavenplugin.utils.FileUtils; import io.github.ascopes.protobufmavenplugin.utils.HostSystem; +import io.github.ascopes.protobufmavenplugin.utils.SystemPathBinaryResolver; import java.io.IOException; import java.net.URL; import java.nio.file.Path; diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java new file mode 100644 index 00000000..6eed0bbd --- /dev/null +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 - 2024, Ashley Scopes. + * + * 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 io.github.ascopes.protobufmavenplugin.utils; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +/** + * Builder for Java argument files that deals with the quoting and escaping rules Java expects. + * + *

See + * https://github.com/openjdk/jdk/blob/2461263aac35b25e2a48b6fc84da49e4b553dbc3/src/java.base/share/native/libjli/args.c#L165-L355 + * for the Java implementation. + * + * @author Ashley Scopes + * @since 2.6.0 + */ +@SuppressWarnings("JavadocLinkAsPlainText") +public final class ArgumentFileBuilder { + private final List arguments; + + public ArgumentFileBuilder() { + arguments = new ArrayList<>(); + } + + public ArgumentFileBuilder add(Object argument) { + arguments.add(argument.toString()); + return this; + } + + public void write(Writer writer) throws IOException { + for (var argument : arguments) { + if (argument.chars().noneMatch(c -> " \n\r\t'\"".indexOf(c) >= 0)) { + writer.append(argument).append("\n"); + continue; + } + + writer.append('"'); + for (var i = 0; i < argument.length(); ++i) { + var nextChar = argument.charAt(i); + switch (nextChar) { + case '"': + writer.append("\\\""); + break; + case '\'': + writer.append("\\'"); + break; + case '\\': + writer.append("\\\\"); + break; + case '\n': + writer.append("\\n"); + break; + case '\r': + writer.append("\\r"); + break; + case '\t': + writer.append("\\t"); + break; + default: + writer.append(nextChar); + break; + } + } + writer.append("\"\n"); + } + } +} diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java index 82a50359..e660a620 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java @@ -30,6 +30,11 @@ * *

Long lines will be split up with line continuations. * + *

I'd eventually like to totally get rid of this class if at all possible. It is (mostly) + * superseded by {@link ArgumentFileBuilder} which enables packaging arguments in a + * platform-agnostic way, but still requires this class to deal with quoting the generated scripts + * referencing those files (albeit with a lower risk of error). + * * @author Ashley Scopes */ public final class Shlex { diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/dependencies/SystemPathBinaryResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/SystemPathBinaryResolver.java similarity index 94% rename from protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/dependencies/SystemPathBinaryResolver.java rename to protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/SystemPathBinaryResolver.java index 48764dca..47c8267a 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/dependencies/SystemPathBinaryResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/SystemPathBinaryResolver.java @@ -14,10 +14,9 @@ * limitations under the License. */ -package io.github.ascopes.protobufmavenplugin.dependencies; +package io.github.ascopes.protobufmavenplugin.utils; -import io.github.ascopes.protobufmavenplugin.utils.FileUtils; -import io.github.ascopes.protobufmavenplugin.utils.HostSystem; +import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java b/protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java new file mode 100644 index 00000000..1563fe6a --- /dev/null +++ b/protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2023 - 2024, Ashley Scopes. + * + * 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 io.github.ascopes.protobufmavenplugin.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("ArgumentFileBuilder tests") +class ArgumentFileBuilderTest { + + @DisplayName("Arguments are converted to a string argument file in the expected format") + @MethodSource("argumentFileCases") + @ParameterizedTest(name = "for argument list {0}") + void argumentsAreConvertedToStringArgumentFileInExpectedFormat( + List givenArguments, + String expectedResult + ) throws IOException { + // Given + var builder = new ArgumentFileBuilder(); + + // When + for (var argument : givenArguments) { + builder.add(argument); + } + + String actualResult; + try (var stringWriter = new StringWriter()) { + builder.write(stringWriter); + actualResult = stringWriter.toString(); + } + + // Then + assertThat(actualResult).isEqualTo(expectedResult); + } + + static Stream argumentFileCases() { + return Stream.of( + // No arguments + arguments( + List.of(), + "" + ), + // Single basic string + arguments( + List.of("-Xmx300m"), + lines("-Xmx300m") + ), + // Complex basic string + arguments( + List.of("-Xms100m", "-Xmx1G", "-XX:+UseZGC", "-ea", "org.example.GreetMe", "Bob", "Jo"), + lines("-Xms100m", "-Xmx1G", "-XX:+UseZGC", "-ea", "org.example.GreetMe", "Bob", "Jo") + ), + // Arguments containing special whitespace characters + arguments( + List.of("start", "foo foo", "bar\r\nbar", "thing", "baz\tbaz", "end"), + lines("start", "\"foo foo\"", "\"bar\\r\\nbar\"", "thing", "\"baz\\tbaz\"", "end") + ), + // Escaping of escape sequences + arguments( + List.of("escaping-the-escape-sequence", "foo \\n bar"), + lines("escaping-the-escape-sequence", "\"foo \\\\n bar\"") + ), + // Escaping of single quotes + arguments( + List.of("xxx", "who'se'n't does this?", "yyy"), + lines("xxx", "\"who\\'se\\'n\\'t does this?\"", "yyy") + ), + // Escaping of double quotes + arguments( + List.of("xxx", "who\"se\"n\"t does this?", "yyy"), + lines("xxx", "\"who\\\"se\\\"n\\\"t does this?\"", "yyy") + ), + // Arguments containing non-string characters + arguments( + List.of(69, 420, "Lol"), + lines("69", "420", "Lol") + ) + ); + } + + static String lines(String... lines) { + return String.join("\n", lines) + "\n"; + } +}