diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index 300483c52..5ac117c7f 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + com.teamscale.`kotlin-convention` com.teamscale.`java-convention` application diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java b/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java deleted file mode 100644 index 9f82fe9e1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java +++ /dev/null @@ -1,201 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.upload.IUploadRetry; -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.jacoco.agent.util.Timer; -import com.teamscale.report.jacoco.CoverageFile; -import com.teamscale.report.jacoco.EmptyReportException; -import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator; -import com.teamscale.report.jacoco.dump.Dump; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.ServerProperties; - -import java.io.File; -import java.io.IOException; -import java.lang.instrument.Instrumentation; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; -import java.util.Properties; -import java.util.stream.Stream; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap; -import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX; - -/** - * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time - * interval. - */ -public class Agent extends AgentBase { - - /** Converts binary data to XML. */ - private final JaCoCoXmlReportGenerator generator; - - /** Regular dump task. */ - private Timer timer; - - /** Stores the XML files. */ - protected final IUploader uploader; - - /** Constructor. */ - public Agent(AgentOptions options, Instrumentation instrumentation) - throws IllegalStateException, UploaderException { - super(options); - - uploader = options.createUploader(instrumentation); - logger.info("Upload method: {}", uploader.describe()); - retryUnsuccessfulUploads(options, uploader); - generator = new JaCoCoXmlReportGenerator(options.getClassDirectoriesOrZips(), - options.getLocationIncludeFilter(), options.getDuplicateClassFileBehavior(), - options.shouldIgnoreUncoveredClasses(), wrap(logger)); - - if (options.shouldDumpInIntervals()) { - timer = new Timer(this::dumpReport, Duration.ofMinutes(options.getDumpIntervalInMinutes())); - timer.start(); - logger.info("Dumping every {} minutes.", options.getDumpIntervalInMinutes()); - } - if (options.getTeamscaleServerOptions().partition != null) { - controller.setSessionId(options.getTeamscaleServerOptions().partition); - } - } - - /** - * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload - * them again with the same configuration as in the previous try. - */ - private void retryUnsuccessfulUploads(AgentOptions options, IUploader uploader) { - Path outputPath = options.getOutputDirectory(); - if (outputPath == null) { - // Default fallback - outputPath = AgentUtils.getAgentDirectory().resolve("coverage"); - } - - Path parentPath = outputPath.getParent(); - if (parentPath == null) { - logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", - outputPath.toAbsolutePath()); - return; - } - - List reuploadCandidates = FileSystemUtils.listFilesRecursively(parentPath.toFile(), - filepath -> filepath.getName().endsWith(RETRY_UPLOAD_FILE_SUFFIX)); - for (File file : reuploadCandidates) { - reuploadCoverageFromPropertiesFile(file, uploader); - } - } - - private void reuploadCoverageFromPropertiesFile(File file, IUploader uploader) { - logger.info("Retrying previously unsuccessful coverage upload for file {}.", file); - try { - Properties properties = FileSystemUtils.readProperties(file); - CoverageFile coverageFile = new CoverageFile( - new File(StringUtils.stripSuffix(file.getAbsolutePath(), RETRY_UPLOAD_FILE_SUFFIX))); - - if (uploader instanceof IUploadRetry) { - ((IUploadRetry) uploader).reupload(coverageFile, properties); - } else { - logger.info("Reupload not implemented for uploader {}", uploader.describe()); - } - Files.deleteIfExists(file.toPath()); - } catch (IOException e) { - logger.error("Reuploading coverage failed. " + e); - } - } - - @Override - protected ResourceConfig initResourceConfig() { - ResourceConfig resourceConfig = new ResourceConfig(); - resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString()); - AgentResource.setAgent(this); - return resourceConfig.register(AgentResource.class).register(GenericExceptionMapper.class); - } - - @Override - protected void prepareShutdown() { - if (timer != null) { - timer.stop(); - } - if (options.shouldDumpOnExit()) { - dumpReport(); - } - - try { - deleteDirectoryIfEmpty(options.getOutputDirectory()); - } catch (IOException e) { - logger.info( - "Could not delete empty output directory {}. " - + "This directory was created inside the configured output directory to be able to " - + "distinguish between different runs of the profiled JVM. You may delete it manually.", - options.getOutputDirectory(), e); - } - } - - /** - * Delete a directory from disk if it is empty. This method does nothing if the path provided does not exist or - * point to a file. - * - * @throws IOException if the deletion of the directory fails - */ - private static void deleteDirectoryIfEmpty(Path directory) throws IOException { - if (!Files.isDirectory(directory)) { - return; - } - - try (Stream stream = Files.list(directory)) { - if (stream.findFirst().isPresent()) { - return; - } - } - - Files.delete(directory); - } - - /** - * Dumps the current execution data, converts it, writes it to the output directory defined in {@link #options} and - * uploads it if an uploader is configured. Logs any errors, never throws an exception. - */ - @Override - public void dumpReport() { - logger.debug("Starting dump"); - - try { - dumpReportUnsafe(); - } catch (Throwable t) { - // we want to catch anything in order to avoid crashing the whole system under - // test - logger.error("Dump job failed with an exception", t); - } - } - - private void dumpReportUnsafe() { - Dump dump; - try { - dump = controller.dumpAndReset(); - } catch (JacocoRuntimeController.DumpException e) { - logger.error("Dumping failed, retrying later", e); - return; - } - - try (Benchmark ignored = new Benchmark("Generating the XML report")) { - File outputFile = options.createNewFileInOutputDirectory("jacoco", "xml"); - CoverageFile coverageFile = generator.convertSingleDumpToReport(dump, outputFile); - uploader.upload(coverageFile); - } catch (IOException e) { - logger.error("Converting binary dump to XML failed", e); - } catch (EmptyReportException e) { - logger.error("No coverage was collected. " + e.getMessage(), e); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java deleted file mode 100644 index 13b98f37e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.eclipse.jetty.util.thread.QueuedThreadPool; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.servlet.ServletContainer; -import org.jacoco.agent.rt.RT; -import org.slf4j.Logger; - -import java.lang.management.ManagementFactory; - -/** - * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the - * {@link JacocoRuntimeController}. - *

- * Subclasses must handle dumping onto disk and uploading via the configured uploader. - */ -public abstract class AgentBase { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** Controls the JaCoCo runtime. */ - public final JacocoRuntimeController controller; - - /** The agent options. */ - protected AgentOptions options; - - private Server server; - - /** Constructor. */ - public AgentBase(AgentOptions options) throws IllegalStateException { - this.options = options; - - try { - controller = new JacocoRuntimeController(RT.getAgent()); - } catch (IllegalStateException e) { - throw new IllegalStateException( - "Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", - e); - } - logger.info("Starting Teamscale Java Profiler for process {} with options: {}", - ManagementFactory.getRuntimeMXBean().getName(), getOptionsObjectToLog()); - if (options.getHttpServerPort() != null) { - try { - initServer(); - } catch (Exception e) { - logger.error("Could not start http server on port " + options.getHttpServerPort() - + ". Please check if the port is blocked."); - throw new IllegalStateException("Control server not started.", e); - } - } - } - - - - /** - * Lazily generated string representation of the command line arguments to print to the log. - */ - private Object getOptionsObjectToLog() { - return new Object() { - @Override - public String toString() { - if (options.shouldObfuscateSecurityRelatedOutputs()) { - return options.getObfuscatedOptionsString(); - } - return options.getOriginalOptionsString(); - } - }; - } - - /** - * Starts the http server, which waits for information about started and finished tests. - */ - private void initServer() throws Exception { - logger.info("Listening for test events on port {}.", options.getHttpServerPort()); - - // Jersey Implementation - ServletContextHandler handler = buildUsingResourceConfig(); - QueuedThreadPool threadPool = new QueuedThreadPool(); - threadPool.setMaxThreads(10); - threadPool.setDaemon(true); - - // Create a server instance and set the thread pool - server = new Server(threadPool); - // Create a server connector, set the port and add it to the server - ServerConnector connector = new ServerConnector(server); - connector.setPort(options.getHttpServerPort()); - server.addConnector(connector); - server.setHandler(handler); - server.start(); - } - - private ServletContextHandler buildUsingResourceConfig() { - ServletContextHandler handler = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); - handler.setContextPath("/"); - - ResourceConfig resourceConfig = initResourceConfig(); - handler.addServlet(new ServletHolder(new ServletContainer(resourceConfig)), "/*"); - return handler; - } - - /** - * Initializes the {@link ResourceConfig} needed for the Jetty + Jersey Server - */ - protected abstract ResourceConfig initResourceConfig(); - - /** - * Registers a shutdown hook that stops the timer and dumps coverage a final time. - */ - void registerShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - logger.info("Teamscale Java Profiler is shutting down..."); - stopServer(); - prepareShutdown(); - logger.info("Teamscale Java Profiler successfully shut down."); - } catch (Exception e) { - logger.error("Exception during profiler shutdown.", e); - } finally { - // Try to flush logging resources also in case of an exception during shutdown - PreMain.closeLoggingResources(); - } - })); - } - - /** Stop the http server if it's running */ - void stopServer() { - if (options.getHttpServerPort() != null) { - try { - server.stop(); - } catch (Exception e) { - logger.error("Could not stop server so it is killed now.", e); - } finally { - server.destroy(); - } - } - } - - /** Called when the shutdown hook is triggered. */ - protected void prepareShutdown() { - // Template method to be overridden by subclasses. - } - - /** - * Dumps the current execution data, converts it, writes it to the output - * directory defined in {@link #options} and uploads it if an uploader is - * configured. Logs any errors, never throws an exception. - */ - public abstract void dumpReport(); - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java b/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java deleted file mode 100644 index 574389c58..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; - -import java.util.ArrayList; -import java.util.List; - -/** - * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff - * needs to be logged before the actual logging framework is initialized. - */ -public class DelayedLogger implements ILogger { - - /** List of log actions that will be executed once the logger is initialized. */ - private final List logActions = new ArrayList<>(); - - @Override - public void debug(String message) { - logActions.add(logger -> logger.debug(message)); - } - - @Override - public void info(String message) { - logActions.add(logger -> logger.info(message)); - } - - @Override - public void warn(String message) { - logActions.add(logger -> logger.warn(message)); - } - - @Override - public void warn(String message, Throwable throwable) { - logActions.add(logger -> logger.warn(message, throwable)); - } - - @Override - public void error(Throwable throwable) { - logActions.add(logger -> logger.error(throwable.getMessage(), throwable)); - } - - @Override - public void error(String message, Throwable throwable) { - logActions.add(logger -> logger.error(message, throwable)); - } - - /** - * Logs an error and also writes the message to {@link System#err} to ensure the message is even logged in case - * setting up the logger itself fails for some reason (see TS-23151). - */ - public void errorAndStdErr(String message, Throwable throwable) { - System.err.println(message); - logActions.add(logger -> logger.error(message, throwable)); - } - - /** Writes the logs to the given slf4j logger. */ - public void logTo(Logger logger) { - logActions.forEach(action -> action.log(logger)); - } - - /** An action to be executed on a logger. */ - private interface ILoggerAction { - - /** Executes the action on the given logger. */ - void log(Logger logger); - - } -} - diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java b/agent/src/main/java/com/teamscale/jacoco/agent/Main.java deleted file mode 100644 index 20c92dae8..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.beust.jcommander.JCommander; -import com.beust.jcommander.JCommander.Builder; -import com.beust.jcommander.Parameter; -import com.beust.jcommander.ParameterException; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.convert.ConvertCommand; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import org.jacoco.core.JaCoCo; -import org.slf4j.Logger; - -/** Provides a command line interface for interacting with JaCoCo. */ -public class Main { - - /** The logger. */ - private final Logger logger = LoggingUtils.getLogger(this); - - /** The default arguments that will always be parsed. */ - private final DefaultArguments defaultArguments = new DefaultArguments(); - - /** The arguments for the one-time conversion process. */ - private final ConvertCommand command = new ConvertCommand(); - - /** Entry point. */ - public static void main(String[] args) throws Exception { - new Main().parseCommandLineAndRun(args); - } - - /** - * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid. - * Then runs the specified command. - */ - private void parseCommandLineAndRun(String[] args) throws Exception { - Builder builder = createJCommanderBuilder(); - JCommander jCommander = builder.build(); - - try { - jCommander.parse(args); - } catch (ParameterException e) { - handleInvalidCommandLine(jCommander, e.getMessage()); - } - - if (defaultArguments.help) { - System.out.println( - "Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION); - jCommander.usage(); - return; - } - - Validator validator = command.validate(); - if (!validator.isValid()) { - handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.getErrorMessage()); - } - - logger.info( - "Starting Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION); - command.run(); - } - - /** Creates a builder for a {@link JCommander} object. */ - private Builder createJCommanderBuilder() { - return JCommander.newBuilder().programName(Main.class.getName()).addObject(defaultArguments).addObject(command); - } - - /** Shows an informative error and help message. Then exits the program. */ - private static void handleInvalidCommandLine(JCommander jCommander, String message) { - System.err.println("Invalid command line: " + message + StringUtils.LINE_FEED); - jCommander.usage(); - System.exit(1); - } - - /** Default arguments that may always be provided. */ - private static class DefaultArguments { - - /** Shows the help message. */ - @Parameter(names = "--help", help = true, description = "Shows all available command line arguments.") - private boolean help; - - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java deleted file mode 100644 index fb539824e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent; -import com.teamscale.report.testwise.model.RevisionInfo; -import org.jetbrains.annotations.Contract; -import org.slf4j.Logger; - -import javax.ws.rs.BadRequestException; -import javax.ws.rs.GET; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.util.Optional; - - -/** - * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link AgentBase}. - */ -public abstract class ResourceBase { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** - * The agentBase inject via {@link AgentResource#setAgent(Agent)} or - * {@link com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource#setAgent(TestwiseCoverageAgent)}. - */ - protected static AgentBase agentBase; - - /** Returns the partition for the Teamscale upload. */ - @GET - @Path("/partition") - public String getPartition() { - return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().partition).orElse(""); - } - - /** Returns the upload message for the Teamscale upload. */ - @GET - @Path("/message") - public String getMessage() { - return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().getMessage()) - .orElse(""); - } - - /** Returns revision information for the Teamscale upload. */ - @GET - @Path("/revision") - @Produces(MediaType.APPLICATION_JSON) - public RevisionInfo getRevision() { - return this.getRevisionInfo(); - } - - /** Returns revision information for the Teamscale upload. */ - @GET - @Path("/commit") - @Produces(MediaType.APPLICATION_JSON) - public RevisionInfo getCommit() { - return this.getRevisionInfo(); - } - - /** Handles setting the partition name. */ - @PUT - @Path("/partition") - public Response setPartition(String partitionString) { - String partition = StringUtils.removeDoubleQuotes(partitionString); - if (partition == null || partition.isEmpty()) { - handleBadRequest("The new partition name is missing in the request body! Please add it as plain text."); - } - - logger.debug("Changing partition name to " + partition); - agentBase.dumpReport(); - agentBase.controller.setSessionId(partition); - agentBase.options.getTeamscaleServerOptions().partition = partition; - return Response.noContent().build(); - } - - /** Handles setting the upload message. */ - @PUT - @Path("/message") - public Response setMessage(String messageString) { - String message = StringUtils.removeDoubleQuotes(messageString); - if (message == null || message.isEmpty()) { - handleBadRequest("The new message is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - logger.debug("Changing message to " + message); - agentBase.options.getTeamscaleServerOptions().setMessage(message); - - return Response.noContent().build(); - } - - /** Handles setting the revision. */ - @PUT - @Path("/revision") - public Response setRevision(String revisionString) { - String revision = StringUtils.removeDoubleQuotes(revisionString); - if (revision == null || revision.isEmpty()) { - handleBadRequest("The new revision name is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - logger.debug("Changing revision name to " + revision); - agentBase.options.getTeamscaleServerOptions().revision = revision; - - return Response.noContent().build(); - } - - /** Handles setting the upload commit. */ - @PUT - @Path("/commit") - public Response setCommit(String commitString) { - String commit = StringUtils.removeDoubleQuotes(commitString); - if (commit == null || commit.isEmpty()) { - handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - agentBase.options.getTeamscaleServerOptions().commit = CommitDescriptor.parse(commit); - - return Response.noContent().build(); - } - - /** Returns revision information for the Teamscale upload. */ - private RevisionInfo getRevisionInfo() { - TeamscaleServer server = agentBase.options.getTeamscaleServerOptions(); - return new RevisionInfo(server.commit, server.revision); - } - - /** - * Handles bad requests to the endpoints. - */ - @Contract(value = "_ -> fail") - protected void handleBadRequest(String message) throws BadRequestException { - logger.error(message); - throw new BadRequestException(message); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java deleted file mode 100644 index 40b7ce388..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java +++ /dev/null @@ -1,68 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2017 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.commandline; - -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.util.Assertions; - -import java.util.ArrayList; -import java.util.List; - -/** - * Helper class to allow for multiple validations to occur. - */ -public class Validator { - - /** The found validation problems in the form of error messages for the user. */ - private final List messages = new ArrayList<>(); - - /** Runs the given validation routine. */ - public void ensure(ExceptionBasedValidation validation) { - try { - validation.validate(); - } catch (Exception | AssertionError e) { - messages.add(e.getMessage()); - } - } - - /** - * Interface for a validation routine that throws an exception when it fails. - */ - @FunctionalInterface - public interface ExceptionBasedValidation { - - /** - * Throws an {@link Exception} or {@link AssertionError} if the validation fails. - */ - void validate() throws Exception, AssertionError; - - } - - /** - * Checks that the given condition is true or adds the given error message. - */ - public void isTrue(boolean condition, String message) { - ensure(() -> Assertions.isTrue(condition, message)); - } - - /** - * Checks that the given condition is false or adds the given error message. - */ - public void isFalse(boolean condition, String message) { - ensure(() -> Assertions.isFalse(condition, message)); - } - - /** Returns true if the validation succeeded. */ - public boolean isValid() { - return messages.isEmpty(); - } - - /** Returns an error message with all validation problems that were found. */ - public String getErrorMessage() { - return "- " + String.join(StringUtils.LINE_FEED + "- ", messages); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java deleted file mode 100644 index 116c5fe44..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java +++ /dev/null @@ -1,171 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2017 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.convert; - -import com.beust.jcommander.JCommander; -import com.beust.jcommander.Parameter; -import com.beust.jcommander.Parameters; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commandline.ICommand; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.options.ClasspathUtils; -import com.teamscale.jacoco.agent.options.FilePatternResolver; -import com.teamscale.jacoco.agent.util.Assertions; -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.util.CommandLineLogger; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Encapsulates all command line options for the convert command for parsing with {@link JCommander}. - */ -@Parameters(commandNames = "convert", commandDescription = "Converts a binary .exec coverage file to XML. " + - "Note that the XML report will only contain source file coverage information, but no class coverage.") -public class ConvertCommand implements ICommand { - - /** The directories and/or zips that contain all class files being profiled. */ - @Parameter(names = {"--class-dir", "--jar", "-c"}, required = true, description = "" - + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled." - + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.") - /* package */ List classDirectoriesOrZips = new ArrayList<>(); - - /** - * Wildcard include patterns to apply during JaCoCo's traversal of class files. - */ - @Parameter(names = {"--includes"}, description = "" - + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." - + " Defaults to no filtering. Excludes overrule includes.") - /* package */ List locationIncludeFilters = new ArrayList<>(); - - /** - * Wildcard exclude patterns to apply during JaCoCo's traversal of class files. - */ - @Parameter(names = {"--excludes", "-e"}, description = "" - + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." - + " Defaults to no filtering. Excludes overrule includes.") - /* package */ List locationExcludeFilters = new ArrayList<>(); - - /** The directory to write the XML traces to. */ - @Parameter(names = {"--in", "-i"}, required = true, description = "" + "The binary .exec file(s), test details and " + - "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.") - /* package */ List inputFiles = new ArrayList<>(); - - /** The directory to write the XML traces to. */ - @Parameter(names = {"--out", "-o"}, required = true, description = "" - + "The file to write the generated XML report to.") - /* package */ String outputFile = ""; - - /** Whether to ignore duplicate, non-identical class files. */ - @Parameter(names = {"--duplicates", "-d"}, arity = 1, description = "" - + "Whether to ignore duplicate, non-identical class files." - + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " + - "Options are FAIL, WARN and IGNORE.") - /* package */ EDuplicateClassFileBehavior duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN; - - /** Whether to ignore uncovered class files. */ - @Parameter(names = {"--ignore-uncovered-classes"}, required = false, arity = 1, description = "" - + "Whether to ignore uncovered classes." - + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.") - /* package */ boolean shouldIgnoreUncoveredClasses = false; - - /** Whether testwise coverage or jacoco coverage should be generated. */ - @Parameter(names = {"--testwise-coverage", "-t"}, required = false, arity = 0, description = "Whether testwise " + - "coverage or jacoco coverage should be generated.") - /* package */ boolean shouldGenerateTestwiseCoverage = false; - - /** After how many tests testwise coverage should be split into multiple reports. */ - @Parameter(names = {"--split-after", "-s"}, required = false, arity = 1, description = "After how many tests " + - "testwise coverage should be split into multiple reports (Default is 5000).") - private int splitAfter = 5000; - - /** @see #classDirectoriesOrZips */ - public List getClassDirectoriesOrZips() throws IOException { - return ClasspathUtils - .resolveClasspathTextFiles("class-dir", new FilePatternResolver(new CommandLineLogger()), - classDirectoriesOrZips); - } - - /** @see #locationIncludeFilters */ - public List getLocationIncludeFilters() { - return locationIncludeFilters; - } - - /** @see #locationExcludeFilters */ - public List getLocationExcludeFilters() { - return locationExcludeFilters; - } - - /** @see #inputFiles */ - public List getInputFiles() { - return inputFiles.stream().map(File::new).collect(Collectors.toList()); - } - - /** @see #outputFile */ - public File getOutputFile() { - return new File(outputFile); - } - - /** @see #splitAfter */ - public int getSplitAfter() { - return splitAfter; - } - - /** @see #duplicateClassFileBehavior */ - public EDuplicateClassFileBehavior getDuplicateClassFileBehavior() { - return duplicateClassFileBehavior; - } - - /** Makes sure the arguments are valid. */ - @Override - public Validator validate() { - Validator validator = new Validator(); - - List classDirectoriesOrZips = new ArrayList<>(); - validator.ensure(() -> classDirectoriesOrZips.addAll(getClassDirectoriesOrZips())); - validator.isFalse(classDirectoriesOrZips.isEmpty(), - "You must specify at least one directory or zip that contains class files"); - for (File path : classDirectoriesOrZips) { - validator.isTrue(path.exists(), "Path '" + path + "' does not exist"); - validator.isTrue(path.canRead(), "Path '" + path + "' is not readable"); - } - - for (File inputFile : getInputFiles()) { - validator.isTrue(inputFile.exists() && inputFile.canRead(), - "Cannot read the input file " + inputFile); - } - - validator.ensure(() -> { - Assertions.isFalse(StringUtils.isEmpty(outputFile), "You must specify an output file"); - File outputDir = getOutputFile().getAbsoluteFile().getParentFile(); - FileSystemUtils.ensureDirectoryExists(outputDir); - Assertions.isTrue(outputDir.canWrite(), "Path '" + outputDir + "' is not writable"); - }); - - return validator; - } - - /** {@inheritDoc} */ - @Override - public void run() throws Exception { - Converter converter = new Converter(this); - if (this.shouldGenerateTestwiseCoverage) { - converter.runTestwiseCoverageReportGeneration(); - } else { - converter.runJaCoCoReportGeneration(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java deleted file mode 100644 index e46cc5852..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.teamscale.jacoco.agent.convert; - -import com.teamscale.client.TestDetails; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.report.ReportUtils; -import com.teamscale.report.jacoco.EmptyReportException; -import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator; -import com.teamscale.report.testwise.ETestArtifactFormat; -import com.teamscale.report.testwise.TestwiseCoverageReportWriter; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.factory.TestInfoFactory; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.CommandLineLogger; -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.List; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap; - -/** Converts one .exec binary coverage file to XML. */ -public class Converter { - - /** The command line arguments. */ - private ConvertCommand arguments; - - /** Constructor. */ - public Converter(ConvertCommand arguments) { - this.arguments = arguments; - } - - /** Converts one .exec binary coverage file to XML. */ - public void runJaCoCoReportGeneration() throws IOException { - List jacocoExecutionDataList = ReportUtils - .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()); - - Logger logger = LoggingUtils.getLogger(this); - JaCoCoXmlReportGenerator generator = new JaCoCoXmlReportGenerator(arguments.getClassDirectoriesOrZips(), - getWildcardIncludeExcludeFilter(), arguments.getDuplicateClassFileBehavior(), - arguments.shouldIgnoreUncoveredClasses, - wrap(logger)); - - try (Benchmark benchmark = new Benchmark("Generating the XML report")) { - generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile()); - } catch (EmptyReportException e) { - logger.warn("Converted report was empty.", e); - } - } - - /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */ - public void runTestwiseCoverageReportGeneration() throws IOException, AgentOptionParseException { - List testDetails = ReportUtils.readObjects(ETestArtifactFormat.TEST_LIST, - TestDetails[].class, arguments.getInputFiles()); - List testExecutions = ReportUtils.readObjects(ETestArtifactFormat.TEST_EXECUTION, - TestExecution[].class, arguments.getInputFiles()); - - List jacocoExecutionDataList = ReportUtils - .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()); - ILogger logger = new CommandLineLogger(); - - JaCoCoTestwiseReportGenerator generator = new JaCoCoTestwiseReportGenerator( - arguments.getClassDirectoriesOrZips(), - getWildcardIncludeExcludeFilter(), - arguments.getDuplicateClassFileBehavior(), - logger - ); - - TestInfoFactory testInfoFactory = new TestInfoFactory(testDetails, testExecutions); - - try (Benchmark benchmark = new Benchmark("Generating the testwise coverage report")) { - logger.info( - "Writing report with " + testDetails.size() + " Details/" + testExecutions.size() + " Results"); - - try (TestwiseCoverageReportWriter coverageWriter = new TestwiseCoverageReportWriter(testInfoFactory, - arguments.getOutputFile(), arguments.getSplitAfter(), null)) { - for (File executionDataFile : jacocoExecutionDataList) { - generator.convertAndConsume(executionDataFile, coverageWriter); - } - } - } - } - - private ClasspathWildcardIncludeFilter getWildcardIncludeExcludeFilter() { - return new ClasspathWildcardIncludeFilter( - String.join(":", arguments.getLocationIncludeFilters()), - String.join(":", arguments.getLocationExcludeFilters())); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java deleted file mode 100644 index a6787e085..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java +++ /dev/null @@ -1,59 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.util; - -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -/** - * Triggers a callback in a regular interval. Note that the spawned threads are - * Daemon threads, i.e. they will not prevent the JVM from shutting down. - *

- * The timer will abort if the given {@link #runnable} ever throws an exception. - */ -public class Timer { - - /** Runs the job on a background daemon thread. */ - private final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> { - Thread thread = Executors.defaultThreadFactory().newThread(runnable); - thread.setDaemon(true); - return thread; - }); - - /** The currently running job or null. */ - private ScheduledFuture job = null; - - /** The job to execute periodically. */ - private final Runnable runnable; - - /** Duration between two job executions. */ - private final Duration duration; - - /** Constructor. */ - public Timer(Runnable runnable, Duration duration) { - this.runnable = runnable; - this.duration = duration; - } - - /** Starts the regular job. */ - public synchronized void start() { - if (job != null) { - return; - } - - job = executor.scheduleAtFixedRate(runnable, duration.toMinutes(), duration.toMinutes(), TimeUnit.MINUTES); - } - - /** Stops the regular job, possibly aborting it. */ - public synchronized void stop() { - job.cancel(false); - job = null; - } - -} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt new file mode 100644 index 000000000..eb6ab0ca8 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt @@ -0,0 +1,170 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.upload.IUploadRetry +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.jacoco.CoverageFile +import com.teamscale.report.jacoco.EmptyReportException +import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator +import com.teamscale.report.jacoco.dump.Dump +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.server.ServerProperties +import java.io.File +import java.io.IOException +import java.lang.instrument.Instrumentation +import java.nio.file.Files +import java.util.Timer +import kotlin.concurrent.fixedRateTimer +import kotlin.io.path.deleteIfExists +import kotlin.io.path.listDirectoryEntries +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time + * interval. + */ +class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBase(options) { + /** Converts binary data to XML. */ + private val generator: JaCoCoXmlReportGenerator + + /** Regular dump task. */ + private var timer: Timer? = null + + /** Stores the XML files. */ + private val uploader = options.createUploader(instrumentation) + + /** Constructor. */ + init { + logger.info("Upload method: {}", uploader.describe()) + retryUnsuccessfulUploads(options, uploader) + generator = JaCoCoXmlReportGenerator( + options.getClassDirectoriesOrZips(), + options.locationIncludeFilter, + options.getDuplicateClassFileBehavior(), + options.shouldIgnoreUncoveredClasses(), + LoggingUtils.wrap(logger) + ) + + if (options.shouldDumpInIntervals()) { + val period = options.dumpIntervalInMinutes.toDuration(DurationUnit.MINUTES).inWholeMilliseconds + timer = fixedRateTimer("Teamscale-Java-Profiler", true, period, period) { + dumpReport() + } + logger.info("Dumping every ${options.dumpIntervalInMinutes} minutes.") + } + options.teamscaleServerOptions.partition?.let { partition -> + controller.sessionId = partition + } + } + + /** + * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload + * them again with the same configuration as in the previous try. + */ + private fun retryUnsuccessfulUploads(options: AgentOptions, uploader: IUploader) { + var outputPath = options.outputDirectory + if (outputPath == null) { + // Default fallback + outputPath = AgentUtils.getAgentDirectory().resolve("coverage") + } + + val parentPath = outputPath.parent + if (parentPath == null) { + logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", outputPath.toAbsolutePath()) + return + } + + parentPath.toFile().walk() + .filter { it.name.endsWith(TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX) } + .forEach { file -> + reuploadCoverageFromPropertiesFile(file, uploader) + } + } + + private fun reuploadCoverageFromPropertiesFile(file: File, uploader: IUploader) { + logger.info("Retrying previously unsuccessful coverage upload for file {}.", file) + try { + val properties = FileSystemUtils.readProperties(file) + val coverageFile = CoverageFile( + File(StringUtils.stripSuffix(file.absolutePath, TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX)) + ) + + if (uploader is IUploadRetry) { + uploader.reupload(coverageFile, properties) + } else { + logger.info("Reupload not implemented for uploader {}", uploader.describe()) + } + Files.deleteIfExists(file.toPath()) + } catch (e: IOException) { + logger.error("Reuploading coverage failed. $e") + } + } + + override fun initResourceConfig(): ResourceConfig? { + val resourceConfig = ResourceConfig() + resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, true.toString()) + AgentResource.setAgent(this) + return resourceConfig.register(AgentResource::class.java).register(GenericExceptionMapper::class.java) + } + + override fun prepareShutdown() { + timer?.cancel() + if (options.shouldDumpOnExit()) dumpReport() + + val dir = options.outputDirectory + try { + if (dir.listDirectoryEntries().isEmpty()) dir.deleteIfExists() + } catch (e: IOException) { + logger.info( + ("Could not delete empty output directory {}. " + + "This directory was created inside the configured output directory to be able to " + + "distinguish between different runs of the profiled JVM. You may delete it manually."), + dir, e + ) + } + } + + /** + * Dumps the current execution data, converts it, writes it to the output directory defined in [.options] and + * uploads it if an uploader is configured. Logs any errors, never throws an exception. + */ + override fun dumpReport() { + logger.debug("Starting dump") + + try { + dumpReportUnsafe() + } catch (t: Throwable) { + // we want to catch anything in order to avoid crashing the whole system under + // test + logger.error("Dump job failed with an exception", t) + } + } + + private fun dumpReportUnsafe() { + val dump: Dump + try { + dump = controller.dumpAndReset() + } catch (e: JacocoRuntimeController.DumpException) { + logger.error("Dumping failed, retrying later", e) + return + } + + try { + benchmark("Generating the XML report") { + val outputFile = options.createNewFileInOutputDirectory("jacoco", "xml") + val coverageFile = generator.convertSingleDumpToReport(dump, outputFile) + uploader.upload(coverageFile) + } + } catch (e: IOException) { + logger.error("Converting binary dump to XML failed", e) + } catch (e: EmptyReportException) { + logger.error("No coverage was collected. ${e.message}", e) + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt new file mode 100644 index 000000000..bfed53514 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt @@ -0,0 +1,150 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptions +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.ServerConnector +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.eclipse.jetty.util.thread.QueuedThreadPool +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.servlet.ServletContainer +import org.jacoco.agent.rt.RT +import org.slf4j.Logger +import java.lang.management.ManagementFactory + +/** + * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the + * [JacocoRuntimeController]. + * + * + * Subclasses must handle dumping onto disk and uploading via the configured uploader. + */ +abstract class AgentBase( + /** The agent options. */ + @JvmField var options: AgentOptions +) { + /** The logger. */ + val logger: Logger = LoggingUtils.getLogger(this) + + /** Controls the JaCoCo runtime. */ + @JvmField + val controller: JacocoRuntimeController + + private lateinit var server: Server + + /** + * Lazily generated string representation of the command line arguments to print to the log. + */ + private val optionsObjectToLog by lazy { + object { + override fun toString() = + if (options.shouldObfuscateSecurityRelatedOutputs()) { + options.getObfuscatedOptionsString() + } else { + options.getOriginalOptionsString() + } + } + } + + init { + try { + controller = JacocoRuntimeController(RT.getAgent()) + } catch (e: IllegalStateException) { + throw IllegalStateException("Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", e) + } + logger.info( + "Starting Teamscale Java Profiler for process {} with options: {}", + ManagementFactory.getRuntimeMXBean().name, optionsObjectToLog + ) + options.getHttpServerPort()?.let { port -> + try { + initServer() + } catch (e: Exception) { + logger.error("Could not start http server on port $port. Please check if the port is blocked.") + throw IllegalStateException("Control server not started.", e) + } + } + } + + /** + * Starts the http server, which waits for information about started and finished tests. + */ + @Throws(Exception::class) + private fun initServer() { + logger.info("Listening for test events on port {}.", options.getHttpServerPort()) + + // Jersey Implementation + val handler = buildUsingResourceConfig() + val threadPool = QueuedThreadPool() + threadPool.maxThreads = 10 + threadPool.isDaemon = true + + // Create a server instance and set the thread pool + server = Server(threadPool) + // Create a server connector, set the port and add it to the server + val connector = ServerConnector(server) + connector.port = options.getHttpServerPort() + server.addConnector(connector) + server.handler = handler + server.start() + } + + private fun buildUsingResourceConfig(): ServletContextHandler { + val handler = ServletContextHandler(ServletContextHandler.NO_SESSIONS) + handler.contextPath = "/" + + val resourceConfig = initResourceConfig() + handler.addServlet(ServletHolder(ServletContainer(resourceConfig)), "/*") + return handler + } + + /** + * Initializes the [ResourceConfig] needed for the Jetty + Jersey Server + */ + protected abstract fun initResourceConfig(): ResourceConfig? + + /** + * Registers a shutdown hook that stops the timer and dumps coverage a final time. + */ + fun registerShutdownHook() { + Runtime.getRuntime().addShutdownHook(Thread { + try { + logger.info("Teamscale Java Profiler is shutting down...") + stopServer() + prepareShutdown() + logger.info("Teamscale Java Profiler successfully shut down.") + } catch (e: Exception) { + logger.error("Exception during profiler shutdown.", e) + } finally { + // Try to flush logging resources also in case of an exception during shutdown + PreMain.closeLoggingResources() + } + }) + } + + /** Stop the http server if it's running */ + fun stopServer() { + options.getHttpServerPort()?.let { + try { + server.stop() + } catch (e: Exception) { + logger.error("Could not stop server so it is killed now.", e) + } finally { + server.destroy() + } + } + } + + /** Called when the shutdown hook is triggered. */ + protected open fun prepareShutdown() { + // Template method to be overridden by subclasses. + } + + /** + * Dumps the current execution data, converts it, writes it to the output + * directory defined in [.options] and uploads it if an uploader is + * configured. Logs any errors, never throws an exception. + */ + abstract fun dumpReport() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt new file mode 100644 index 000000000..d242ff79d --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt @@ -0,0 +1,52 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.report.util.ILogger +import org.slf4j.Logger +import java.util.function.Consumer + +/** + * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff + * needs to be logged before the actual logging framework is initialized. + */ +class DelayedLogger : ILogger { + /** List of log actions that will be executed once the logger is initialized. */ + private val logActions = mutableListOf Unit>() + + override fun debug(message: String) { + logActions.add { debug(message) } + } + + override fun info(message: String) { + logActions.add { info(message) } + } + + override fun warn(message: String) { + logActions.add { warn(message) } + } + + override fun warn(message: String, throwable: Throwable?) { + logActions.add { warn(message, throwable) } + } + + override fun error(throwable: Throwable) { + logActions.add { error(throwable.message, throwable) } + } + + override fun error(message: String, throwable: Throwable?) { + logActions.add { error(message, throwable) } + } + + /** + * Logs an error and also writes the message to [System.err] to ensure the message is even logged in case + * setting up the logger itself fails for some reason (see TS-23151). + */ + fun errorAndStdErr(message: String?, throwable: Throwable?) { + System.err.println(message) + logActions.add { error(message, throwable) } + } + + /** Writes the logs to the given slf4j logger. */ + fun logTo(logger: Logger) { + logActions.forEach { action -> action(logger) } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt new file mode 100644 index 000000000..d3dc43a97 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt @@ -0,0 +1,6 @@ +package com.teamscale.jacoco.agent + +import kotlin.time.measureTime + +fun benchmark(name: String, action: () -> Unit) = + measureTime { action() }.also { duration -> Main.logger.debug("$name took $duration") } \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt new file mode 100644 index 000000000..e1efc8a12 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt @@ -0,0 +1,81 @@ +package com.teamscale.jacoco.agent + +import com.beust.jcommander.JCommander +import com.beust.jcommander.Parameter +import com.beust.jcommander.ParameterException +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.convert.ConvertCommand +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.util.AgentUtils +import org.jacoco.core.JaCoCo +import org.slf4j.Logger +import kotlin.system.exitProcess + +/** Provides a command line interface for interacting with JaCoCo. */ +object Main { + /** The logger. */ + val logger: Logger = LoggingUtils.getLogger(this) + + /** The default arguments that will always be parsed. */ + private val defaultArguments = DefaultArguments() + + /** The arguments for the one-time conversion process. */ + private val command = ConvertCommand() + + /** Entry point. */ + @Throws(Exception::class) + @JvmStatic + fun main(args: Array) { + parseCommandLineAndRun(args) + } + + /** + * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid. + * Then runs the specified command. + */ + @Throws(Exception::class) + private fun parseCommandLineAndRun(args: Array) { + val builder = createJCommanderBuilder() + val jCommander = builder.build() + + try { + jCommander.parse(*args) + } catch (e: ParameterException) { + handleInvalidCommandLine(jCommander, e.message) + } + + if (defaultArguments.help) { + println("Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}") + jCommander.usage() + return + } + + val validator = command.validate() + if (!validator.isValid) { + handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.errorMessage) + } + + logger.info("Starting Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}") + command.run() + } + + /** Shows an informative error and help message. Then exits the program. */ + private fun handleInvalidCommandLine(jCommander: JCommander, message: String?) { + System.err.println("Invalid command line: $message${StringUtils.LINE_FEED}") + jCommander.usage() + exitProcess(1) + } + + /** Creates a builder for a [com.beust.jcommander.JCommander] object. */ + private fun createJCommanderBuilder() = + JCommander.newBuilder().programName(Main::class.java.getName()) + .addObject(defaultArguments) + .addObject(command) + + /** Default arguments that may always be provided. */ + private class DefaultArguments { + /** Shows the help message. */ + @Parameter(names = ["--help"], help = true, description = "Shows all available command line arguments.") + val help = false + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt new file mode 100644 index 000000000..fd4bb77cc --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt @@ -0,0 +1,141 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.report.testwise.model.RevisionInfo +import org.jetbrains.annotations.Contract +import org.slf4j.Logger +import java.util.Optional +import javax.ws.rs.BadRequestException +import javax.ws.rs.GET +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [AgentBase]. + */ +abstract class ResourceBase { + /** The logger. */ + @JvmField + protected val logger: Logger = LoggingUtils.getLogger(this) + + companion object { + /** + * The agentBase inject via [AgentResource.setAgent] or + * [com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.setAgent]. + */ + @JvmStatic + protected lateinit var agentBase: AgentBase + } + + @get:Path("/partition") + @get:GET + val partition: String + /** Returns the partition for the Teamscale upload. */ + get() = agentBase.options.teamscaleServerOptions.partition.orEmpty() + + @get:Path("/message") + @get:GET + val message: String + /** Returns the upload message for the Teamscale upload. */ + get() = agentBase.options.teamscaleServerOptions.message.orEmpty() + + @get:Produces(MediaType.APPLICATION_JSON) + @get:Path("/revision") + @get:GET + val revision: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() = revisionInfo + + @get:Produces(MediaType.APPLICATION_JSON) + @get:Path("/commit") + @get:GET + val commit: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() = revisionInfo + + /** Handles setting the partition name. */ + @PUT + @Path("/partition") + fun setPartition(partitionString: String): Response { + val partition = StringUtils.removeDoubleQuotes(partitionString) + if (partition.isEmpty()) { + handleBadRequest("The new partition name is missing in the request body! Please add it as plain text.") + } + + logger.debug("Changing partition name to $partition") + agentBase.dumpReport() + agentBase.controller.sessionId = partition + agentBase.options.teamscaleServerOptions.partition = partition + return Response.noContent().build() + } + + /** Handles setting the upload message. */ + @PUT + @Path("/message") + fun setMessage(messageString: String): Response { + val message = StringUtils.removeDoubleQuotes(messageString) + if (message.isEmpty()) { + handleBadRequest("The new message is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + logger.debug("Changing message to $message") + agentBase.options.teamscaleServerOptions.message = message + + return Response.noContent().build() + } + + /** Handles setting the revision. */ + @PUT + @Path("/revision") + fun setRevision(revisionString: String): Response { + val revision = StringUtils.removeDoubleQuotes(revisionString) + if (revision.isEmpty()) { + handleBadRequest("The new revision name is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + logger.debug("Changing revision name to $revision") + agentBase.options.teamscaleServerOptions.revision = revision + + return Response.noContent().build() + } + + /** Handles setting the upload commit. */ + @PUT + @Path("/commit") + fun setCommit(commitString: String): Response { + val commit = StringUtils.removeDoubleQuotes(commitString) + if (commit.isEmpty()) { + handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + agentBase.options.teamscaleServerOptions.commit = CommitDescriptor.parse(commit) + + return Response.noContent().build() + } + + private val revisionInfo: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() { + val server = agentBase.options.teamscaleServerOptions + return RevisionInfo(server.commit, server.revision) + } + + /** + * Handles bad requests to the endpoints. + */ + @Contract(value = "_ -> fail") + @Throws(BadRequestException::class) + protected fun handleBadRequest(message: String?) { + logger.error(message) + throw BadRequestException(message) + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt similarity index 77% rename from agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt index 4ce2fa697..5bbc9b7cd 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt @@ -3,27 +3,26 @@ | Copyright (c) 2009-2017 CQSE GmbH | | | +-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.commandline; +package com.teamscale.jacoco.agent.commandline -import com.teamscale.jacoco.agent.options.AgentOptionParseException; - -import java.io.IOException; +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import java.io.IOException /** * Interface for commands: argument parsing and execution. */ -public interface ICommand { - +interface ICommand { /** * Makes sure the arguments are valid. Must return all detected problems in the * form of a user-visible message. */ - Validator validate() throws AgentOptionParseException, IOException; + @Throws(AgentOptionParseException::class, IOException::class) + fun validate(): Validator /** * Runs the implementation of the command. May throw an exception to indicate * abnormal termination of the program. */ - void run() throws Exception; - + @Throws(Exception::class) + fun run() } \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt new file mode 100644 index 000000000..6f520dc7d --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt @@ -0,0 +1,61 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2017 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.jacoco.agent.commandline + +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.util.Assertions + +/** + * Helper class to allow for multiple validations to occur. + */ +class Validator { + /** The found validation problems in the form of error messages for the user. */ + private val messages = mutableListOf() + + /** Runs the given validation routine. */ + fun ensure(validation: ExceptionBasedValidation) { + try { + validation.validate() + } catch (e: Exception) { + e.message?.let { messages.add(it) } + } catch (e: AssertionError) { + e.message?.let { messages.add(it) } + } + } + + /** + * Interface for a validation routine that throws an exception when it fails. + */ + fun interface ExceptionBasedValidation { + /** + * Throws an [Exception] or [AssertionError] if the validation fails. + */ + @Throws(Exception::class, AssertionError::class) + fun validate() + } + + /** + * Checks that the given condition is `true` or adds the given error message. + */ + fun isTrue(condition: Boolean, message: String?) { + ensure { Assertions.isTrue(condition, message) } + } + + /** + * Checks that the given condition is `false` or adds the given error message. + */ + fun isFalse(condition: Boolean, message: String?) { + ensure { Assertions.isFalse(condition, message) } + } + + val isValid: Boolean + /** Returns `true` if the validation succeeded. */ + get() = messages.isEmpty() + + val errorMessage: String + /** Returns an error message with all validation problems that were found. */ + get() = "- ${messages.joinToString("${StringUtils.LINE_FEED}- ")}" +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt new file mode 100644 index 000000000..2183de6f5 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt @@ -0,0 +1,156 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2017 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.jacoco.agent.convert + +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters +import com.teamscale.client.FileSystemUtils.ensureDirectoryExists +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.jacoco.agent.commandline.ICommand +import com.teamscale.jacoco.agent.commandline.Validator +import com.teamscale.jacoco.agent.options.ClasspathUtils +import com.teamscale.jacoco.agent.options.FilePatternResolver +import com.teamscale.jacoco.agent.util.Assertions +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.CommandLineLogger +import java.io.File +import java.io.IOException + +/** + * Encapsulates all command line options for the convert command for parsing with [JCommander]. + */ +@Parameters( + commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to XML. " + + "Note that the XML report will only contain source file coverage information, but no class coverage." +) +class ConvertCommand : ICommand { + /** The directories and/or zips that contain all class files being profiled. */ + @JvmField + @Parameter( + names = ["--class-dir", "--jar", "-c"], required = true, description = ("" + + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled." + + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.") + ) + var classDirectoriesOrZips = mutableListOf() + + /** + * Wildcard include patterns to apply during JaCoCo's traversal of class files. + */ + @Parameter( + names = ["--includes"], description = ("" + + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." + + " Note that zip contents are separated from zip files with @ and that you can filter only" + + " class files, not intermediate folders/zips. Use with great care as missing class files" + + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + " Defaults to no filtering. Excludes overrule includes.") + ) + var locationIncludeFilters = mutableListOf() + + /** + * Wildcard exclude patterns to apply during JaCoCo's traversal of class files. + */ + @Parameter( + names = ["--excludes", "-e"], description = ("" + + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." + + " Note that zip contents are separated from zip files with @ and that you can filter only" + + " class files, not intermediate folders/zips. Use with great care as missing class files" + + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + " Defaults to no filtering. Excludes overrule includes.") + ) + var locationExcludeFilters = mutableListOf() + + /** The directory to write the XML traces to. */ + @JvmField + @Parameter( + names = ["--in", "-i"], required = true, description = ("" + "The binary .exec file(s), test details and " + + "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.") + ) + var inputFiles = mutableListOf() + + /** The directory to write the XML traces to. */ + @JvmField + @Parameter( + names = ["--out", "-o"], required = true, description = ("" + + "The file to write the generated XML report to.") + ) + var outputFile = "" + + /** Whether to ignore duplicate, non-identical class files. */ + @Parameter( + names = ["--duplicates", "-d"], arity = 1, description = ("" + + "Whether to ignore duplicate, non-identical class files." + + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " + + "Options are FAIL, WARN and IGNORE.") + ) + var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN + + /** Whether to ignore uncovered class files. */ + @Parameter( + names = ["--ignore-uncovered-classes"], required = false, arity = 1, description = ("" + + "Whether to ignore uncovered classes." + + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.") + ) + var shouldIgnoreUncoveredClasses = false + + /** Whether testwise coverage or jacoco coverage should be generated. */ + @Parameter( + names = ["--testwise-coverage", "-t"], required = false, arity = 0, description = "Whether testwise " + + "coverage or jacoco coverage should be generated." + ) + var shouldGenerateTestwiseCoverage = false + + /** After how many tests testwise coverage should be split into multiple reports. */ + @Parameter( + names = ["--split-after", "-s"], required = false, arity = 1, description = "After how many tests " + + "testwise coverage should be split into multiple reports (Default is 5000)." + ) + val splitAfter = 5000 + + @Throws(IOException::class) + fun getClassDirectoriesOrZips(): List = ClasspathUtils + .resolveClasspathTextFiles( + "class-dir", FilePatternResolver(CommandLineLogger()), + classDirectoriesOrZips + ) + + fun getInputFiles() = inputFiles.map { File(it) } + fun getOutputFile() = File(outputFile) + + /** Makes sure the arguments are valid. */ + override fun validate() = Validator().apply { + val classDirectoriesOrZips = mutableListOf() + ensure { classDirectoriesOrZips.addAll(getClassDirectoriesOrZips()) } + isFalse( + classDirectoriesOrZips.isEmpty(), + "You must specify at least one directory or zip that contains class files" + ) + classDirectoriesOrZips.forEach { path -> + isTrue(path.exists(), "Path '$path' does not exist") + isTrue(path.canRead(), "Path '$path' is not readable") + } + getInputFiles().forEach { inputFile -> + isTrue(inputFile.exists() && inputFile.canRead(), "Cannot read the input file $inputFile") + } + ensure { + Assertions.isFalse(isEmpty(outputFile), "You must specify an output file") + val outputDir = getOutputFile().getAbsoluteFile().getParentFile() + ensureDirectoryExists(outputDir) + Assertions.isTrue(outputDir.canWrite(), "Path '$outputDir' is not writable") + } + } + + /** {@inheritDoc} */ + @Throws(Exception::class) + override fun run() { + Converter(this).apply { + if (shouldGenerateTestwiseCoverage) { + runTestwiseCoverageReportGeneration() + } else { + runJaCoCoReportGeneration() + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt new file mode 100644 index 000000000..0506d8acf --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt @@ -0,0 +1,99 @@ +package com.teamscale.jacoco.agent.convert + +import com.teamscale.client.TestDetails +import com.teamscale.jacoco.agent.benchmark +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.util.Benchmark +import com.teamscale.report.ReportUtils +import com.teamscale.report.ReportUtils.listFiles +import com.teamscale.report.jacoco.EmptyReportException +import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator +import com.teamscale.report.testwise.ETestArtifactFormat +import com.teamscale.report.testwise.TestwiseCoverageReportWriter +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.factory.TestInfoFactory +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.CommandLineLogger +import com.teamscale.report.util.ILogger +import java.io.File +import java.io.IOException +import java.lang.String +import java.nio.file.Paths +import kotlin.Array +import kotlin.Throws +import kotlin.use + +/** Converts one .exec binary coverage file to XML. */ +class Converter +/** Constructor. */( + /** The command line arguments. */ + private val arguments: ConvertCommand +) { + /** Converts one .exec binary coverage file to XML. */ + @Throws(IOException::class) + fun runJaCoCoReportGeneration() { + val logger = LoggingUtils.getLogger(this) + val generator = JaCoCoXmlReportGenerator( + arguments.getClassDirectoriesOrZips(), + wildcardIncludeExcludeFilter, + arguments.duplicateClassFileBehavior, + arguments.shouldIgnoreUncoveredClasses, + LoggingUtils.wrap(logger) + ) + + val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()) + try { + benchmark("Generating the XML report") { + generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile()) + } + } catch (e: EmptyReportException) { + logger.warn("Converted report was empty.", e) + } + } + + /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */ + @Throws(IOException::class, AgentOptionParseException::class) + fun runTestwiseCoverageReportGeneration() { + val testDetails = ReportUtils.readObjects( + ETestArtifactFormat.TEST_LIST, + Array::class.java, + arguments.getInputFiles() + ) + val testExecutions = ReportUtils.readObjects( + ETestArtifactFormat.TEST_EXECUTION, + Array::class.java, + arguments.getInputFiles() + ) + + val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()) + val logger = CommandLineLogger() + + val generator = JaCoCoTestwiseReportGenerator( + arguments.getClassDirectoriesOrZips(), + this.wildcardIncludeExcludeFilter, + arguments.duplicateClassFileBehavior, + logger + ) + + benchmark("Generating the testwise coverage report") { + logger.info("Writing report with ${testDetails.size} Details/${testExecutions.size} Results") + TestwiseCoverageReportWriter( + TestInfoFactory(testDetails, testExecutions), + arguments.getOutputFile(), + arguments.splitAfter, null + ).use { coverageWriter -> + jacocoExecutionDataList.forEach { executionDataFile -> + generator.convertAndConsume(executionDataFile, coverageWriter) + } + } + } + } + + private val wildcardIncludeExcludeFilter: ClasspathWildcardIncludeFilter + get() = ClasspathWildcardIncludeFilter( + String.join(":", arguments.locationIncludeFilters), + String.join(":", arguments.locationExcludeFilters) + ) +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt index 64800f45d..3e2f1169d 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt @@ -139,7 +139,7 @@ object StringUtils { * list is returned. */ @JvmStatic - fun splitLinesAsList(content: String?): List = content?.lines() ?: emptyList() + fun splitLinesAsList(content: String?) = content?.lines() ?: emptyList() /** * Test if a string ends with one of the provided suffixes. Returns @@ -147,18 +147,14 @@ object StringUtils { * for short lists of suffixes. */ @JvmStatic - fun endsWithOneOf(string: String, vararg suffixes: String): Boolean { - return suffixes.any { string.endsWith(it) } - } + fun endsWithOneOf(string: String, vararg suffixes: String) = suffixes.any { string.endsWith(it) } /** * Removes double quotes from beginning and end (if present) and returns the new * string. */ @JvmStatic - fun removeDoubleQuotes(string: String): String { - return string.removeSuffix("\"").removePrefix("\"") - } + fun removeDoubleQuotes(string: String) = string.removeSuffix("\"").removePrefix("\"") /** * Converts an empty string to null. If the input string is not empty, it returns the string unmodified. @@ -167,9 +163,7 @@ object StringUtils { * @return `null` if the input string is empty after trimming; the original string otherwise. */ @JvmStatic - fun emptyToNull(string: String): String? { - return if (isEmpty(string)) null else string - } + fun emptyToNull(string: String) = if (isEmpty(string)) null else string /** * Converts a nullable string to a non-null, empty string. @@ -179,7 +173,5 @@ object StringUtils { * @return a non-null string; either the original string or an empty string if the input was null */ @JvmStatic - fun nullToEmpty(stringOrNull: String?): String { - return stringOrNull ?: EMPTY_STRING - } + fun nullToEmpty(stringOrNull: String?) = stringOrNull ?: EMPTY_STRING }