From d113e8f1c24b41e669383fad10f633f0dce386ae Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Fri, 29 Aug 2025 18:05:21 +0200 Subject: [PATCH 1/3] feat: we now support @argument-files --- .gitignore | 1 + README.md | 45 ++- app.yml | 2 + misc/grammar.txt | 5 + src/main/java/org/codejive/jpm/Jpm.java | 35 +- src/main/java/org/codejive/jpm/Main.java | 65 +++- .../java/org/codejive/jpm/config/AppInfo.java | 2 + .../org/codejive/jpm/util/CommandsParser.java | 199 +++++++++++ .../java/org/codejive/jpm/util/FileUtils.java | 10 + .../org/codejive/jpm/util/ScriptUtils.java | 313 +++++++++++++++--- .../jpm/DoCommandPerformanceTest.java | 25 +- .../codejive/jpm/util/ScriptUtilsTest.java | 149 +++++++-- 12 files changed, 743 insertions(+), 108 deletions(-) create mode 100644 misc/grammar.txt create mode 100644 src/main/java/org/codejive/jpm/util/CommandsParser.java diff --git a/.gitignore b/.gitignore index 8c08bad..c1d2ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ deps/ +classes/ ### IntelliJ IDEA ### .idea/ diff --git a/README.md b/README.md index 0e4471c..1304a88 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,7 @@ But you can also simply download and unzip the [release package](https://github. See: -```shell -$ jpm --help +``` Usage: jpm [-hV] [COMMAND] Simple command line tool for managing Maven artifacts -h, --help Show this help message and exit. @@ -217,12 +216,48 @@ Commands: Example: jpm path org.apache.httpcomponents:httpclient:4.5.14 - do Executes an action command defined in the app.yml file. Actions - can use variable substitution for classpath. + exec Executes a shell command that can use special tokens to deal with + OS-specific quirks like paths. This means that commands can be + written in a somewhat platform independent way and will work on + Windows, Linux and MacOS. + + Supported tokens and what they expand to: + {{deps}} : the classpath of all dependencies defined in the app.yml file + {/} : the OS' file path separator + {:} : the OS' class path separator + {~} : the user's home directory using the OS' class path format + {;} : the OS' command separator + {./file/path} : a path using the OS' path format (must start with './'!) + {./lib:./ext} : a class path using the OS' class path format (must start with './'!) + @[ ... ] : writes contents to a file and inserts @ instead + + In actuality the command is pretty smart and will try to do the + right thing, as long as {{deps}} is the only token you use. In + the examples below the first line shows how to do it the hard + way, by specifying everything manually, while the second line + shows how much easier it is when you can rely on the built-in + smart feature. Is the smart feature bothering you? Just use any + of the other tokens besides {{deps}} and it will be turned off. + By default args files will only be considered for Java commands + that are know to support them (java, javac, javadoc, etc), but + you can indicate that your command supports it as well by + adding a single @ as the first character of the command. + Example: + jpm exec javac -cp @[{{deps}}] -d {./out/classes} --source-path {./src/main/java} App.java + jpm exec javac -cp {{deps}} -d out/classes --source-path src/main/java App.java + jpm exec @kotlinc -cp {{deps}} -d out/classes src/main/kotlin/App.kt + + do Executes an action command defined in the app.yml file. The + command is executed using the same rules as the exec command, + so it can use all the same tokens and features. You can also + pass additional arguments to the action using -a or --arg + followed by the argument value. You can chain multiple actions + and their arguments in a single command line. Example: jpm do build - jpm do test + jpm do test --arg verbose + jpm do build -a --fresh test -a verbose clean Executes the 'clean' action as defined in the app.yml file. build Executes the 'build' action as defined in the app.yml file. diff --git a/app.yml b/app.yml index 94a6610..f2f9a3a 100644 --- a/app.yml +++ b/app.yml @@ -13,3 +13,5 @@ actions: build: ".{/}mvnw spotless:apply package -DskipTests" run: "{./target/binary/bin/jpm}" test: ".{/}mvnw test" + buildj: "javac -cp {{deps}} -d classes --source-path src/main/java src/main/java/org/codejive/jpm/Main.java" + runj: "java -cp classes:{{deps}} org.codejive.jpm.Main" diff --git a/misc/grammar.txt b/misc/grammar.txt new file mode 100644 index 0000000..b4993ad --- /dev/null +++ b/misc/grammar.txt @@ -0,0 +1,5 @@ +commands ::= + +elem ::= | | +separator ::= ';' | '&&' | '||' +group ::= '(' ')' +command ::= + diff --git a/src/main/java/org/codejive/jpm/Jpm.java b/src/main/java/org/codejive/jpm/Jpm.java index a459e58..327aa1f 100644 --- a/src/main/java/org/codejive/jpm/Jpm.java +++ b/src/main/java/org/codejive/jpm/Jpm.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.*; +import java.util.stream.Collectors; import org.codejive.jpm.config.AppInfo; import org.codejive.jpm.util.*; import org.eclipse.aether.artifact.Artifact; @@ -240,7 +241,7 @@ private Map getRepositories(Map extraRepos, AppI /** * Executes an action defined in app.yml file. * - * @param actionName The name of the action to execute (null to list actions) + * @param actionName The name of the action to execute * @return An integer containing the exit result of the action * @throws IllegalArgumentException If the action name is not provided or not found * @throws IOException If an error occurred during the operation @@ -260,13 +261,15 @@ public int executeAction(String actionName, List args) + "' not found in app.yml. Use --list to see available actions."); } - // Get the classpath for variable substitution only if needed - List classpath = Collections.emptyList(); - if (command.contains("{{deps}}")) { - classpath = this.path(new String[0]); // Empty array means use dependencies from app.yml + // Add the user arguments to the command + if (args != null && !args.isEmpty()) { + command += + args.stream() + .map(ScriptUtils::quoteArgument) + .collect(Collectors.joining(" ", " ", "")); } - return ScriptUtils.executeScript(command, args, classpath, verbose); + return executeCommand(command); } /** @@ -279,4 +282,24 @@ public List listActions() throws IOException { AppInfo appInfo = AppInfo.read(); return new ArrayList<>(appInfo.getActionNames()); } + + /** + * Executes an action defined in app.yml file. + * + * @param command The command to execute + * @return An integer containing the exit result of the action + * @throws IOException If an error occurred during the operation + * @throws DependencyResolutionException If an error occurred during dependency resolution + * @throws InterruptedException If the action execution was interrupted + */ + public int executeCommand(String command) + throws IOException, DependencyResolutionException, InterruptedException { + // Get the classpath for variable substitution only if needed + List classpath = Collections.emptyList(); + if (command.contains("{{deps}}")) { + classpath = this.path(new String[0]); // Empty array means use dependencies from app.yml + } + + return ScriptUtils.executeScript(command, classpath, verbose); + } } diff --git a/src/main/java/org/codejive/jpm/Main.java b/src/main/java/org/codejive/jpm/Main.java index e35ff38..694f273 100644 --- a/src/main/java/org/codejive/jpm/Main.java +++ b/src/main/java/org/codejive/jpm/Main.java @@ -4,8 +4,8 @@ //DEPS org.yaml:snakeyaml:2.4 //DEPS org.jline:jline-console-ui:3.30.5 org.jline:jline-terminal-jni:3.30.5 //DEPS org.slf4j:slf4j-api:2.0.17 org.slf4j:slf4j-simple:2.0.17 -//SOURCES Jpm.java config/AppInfo.java util/FileUtils.java util/Resolver.java util/ScriptUtils.java -//SOURCES util/SearchResult.java util/SearchUtils.java util/SyncStats.java util/Version.java +//SOURCES Jpm.java config/AppInfo.java util/CommandsParser.java util/FileUtils.java util/Resolver.java +//SOURCES util/ScriptUtils.java util/SearchResult.java util/SearchUtils.java util/SyncStats.java util/Version.java // spotless:on package org.codejive.jpm; @@ -49,6 +49,7 @@ Main.Search.class, Main.Install.class, Main.PrintPath.class, + Main.Exec.class, Main.Do.class, Main.Clean.class, Main.Build.class, @@ -331,11 +332,67 @@ public Integer call() throws Exception { } } + @Command( + name = "exec", + description = + "Executes a shell command that can use special tokens to deal with OS-specific quirks like paths." + + " This means that commands can be written in a somewhat platform independent way and will work on Windows, Linux and MacOS.\n" + + "\n" + + "Supported tokens and what they expand to:\n" + + " {{deps}} : the classpath of all dependencies defined in the app.yml file\n" + + " {/} : the OS' file path separator\n" + + " {:} : the OS' class path separator\n" + + " {~} : the user's home directory using the OS' class path format\n" + + " {;} : the OS' command separator\n" + + " {./file/path} : a path using the OS' path format (must start with './'!)\n" + + " {./lib:./ext} : a class path using the OS' class path format (must start with './'!)\n" + + " @[ ... ] : writes contents to a file and inserts @ instead\n" + + "\n" + + "In actuality the command is pretty smart and will try to do the right thing, as long as {{deps}} is the only token you use." + + " In the examples below the first line shows how to do it the hard way, by specifying everything manually, while the second line shows how much easier it is when you can rely on the built-in smart feature." + + " Is the smart feature bothering you? Just use any of the other tokens besides {{deps}} and it will be turned off." + + " By default args files will only be considered for Java commands that are know to support them (java, javac, javadoc, etc), but you can indicate that your command supports it as well by adding a single @ as the first character of the command.\n" + + "\n" + + "Example:\n" + + " jpm exec javac -cp @[{{deps}}] -d {./out/classes} --source-path {./src/main/java} App.java\n" + + " jpm exec javac -cp {{deps}} -d out/classes --source-path src/main/java App.java\n" + + " jpm exec @kotlinc -cp {{deps}} -d out/classes src/main/kotlin/App.kt\n") + static class Exec implements Callable { + @Mixin DepsMixin depsMixin; + @Mixin QuietMixin quietMixin; + + @Parameters(paramLabel = "command", description = "The command to execute", arity = "0..*") + private List command; + + @Override + public Integer call() throws Exception { + String cmd = String.join(" ", command); + try { + return Jpm.builder() + .directory(depsMixin.directory) + .noLinks(depsMixin.noLinks) + .verbose(!quietMixin.quiet) + .build() + .executeCommand(cmd); + } catch (Exception e) { + System.err.println(e.getMessage()); + return 1; + } + } + } + @Command( name = "do", description = - "Executes an action command defined in the app.yml file.\n\n" - + "Example:\n jpm do build\n jpm do test --arg verbose\n") + "Executes an action command defined in the app.yml file." + + " The command is executed using the same rules as the exec command, so it can use all the same tokens and features." + + " You can also pass additional arguments to the action using -a or --arg followed by the argument value." + + " You can chain multiple actions and their arguments in a single command line." + + "\n" + + "Example:\n" + + " jpm do build\n" + + " jpm do test --arg verbose\n" + + " jpm do build -a --fresh test -a verbose\n") static class Do implements Callable { @Mixin DepsMixin depsMixin; @Mixin QuietMixin quietMixin; diff --git a/src/main/java/org/codejive/jpm/config/AppInfo.java b/src/main/java/org/codejive/jpm/config/AppInfo.java index d27152d..3b182a6 100644 --- a/src/main/java/org/codejive/jpm/config/AppInfo.java +++ b/src/main/java/org/codejive/jpm/config/AppInfo.java @@ -61,6 +61,7 @@ public java.util.Set getActionNames() { * @return An instance of AppInfo * @throws IOException if an error occurred while reading or parsing the file */ + @SuppressWarnings("unchecked") public static AppInfo read() throws IOException { Path prjJson = Paths.get(System.getProperty("user.dir"), APP_INFO_FILE); AppInfo appInfo = new AppInfo(); @@ -106,6 +107,7 @@ public static AppInfo read() throws IOException { * @param appInfo The AppInfo object to write * @throws IOException if an error occurred while writing the file */ + @SuppressWarnings("unchecked") public static void write(AppInfo appInfo) throws IOException { Path prjJson = Paths.get(System.getProperty("user.dir"), APP_INFO_FILE); try (Writer out = Files.newBufferedWriter(prjJson)) { diff --git a/src/main/java/org/codejive/jpm/util/CommandsParser.java b/src/main/java/org/codejive/jpm/util/CommandsParser.java new file mode 100644 index 0000000..5a5a827 --- /dev/null +++ b/src/main/java/org/codejive/jpm/util/CommandsParser.java @@ -0,0 +1,199 @@ +package org.codejive.jpm.util; + +import java.util.ArrayList; +import java.util.List; + +/** Parser for shell commands */ +public class CommandsParser { + private final String input; + + private int position; + private String currentToken; + + public CommandsParser(String input) { + this.input = input; + this.position = 0; + nextToken(); + } + + // Parse the entire input according to the grammar + public Commands parse() { + try { + return parseCommands(); + } catch (RuntimeException e) { + return null; + } + } + + // commands ::= + + private Commands parseCommands() { + List elements = new ArrayList<>(); + + while (currentToken != null) { + elements.add(parseElem()); + } + + return new Commands(elements); + } + + // elem ::= | | + private Node parseElem() { + if (currentToken == null) { + throw new RuntimeException("Unexpected end of input"); + } + + if (currentToken.equals("(")) { + return parseGroup(); + } else if (currentToken.equals(";") + || currentToken.equals("&&") + || currentToken.equals("||")) { + return parseSeparator(); + } else { + return parseCommand(); + } + } + + // separator ::= ';' | '&&' | '||' + private Separator parseSeparator() { + String type = currentToken; + nextToken(); + return new Separator(type); + } + + // group ::= '(' ')' + private Group parseGroup() { + consume("("); + Commands commands = parseCommands(); + if (currentToken == null) { + throw new RuntimeException("Unexpected end of input, missing ')'"); + } + consume(")"); + return new Group(commands); + } + + // ::= + + private Command parseCommand() { + List words = new ArrayList<>(); + + while (currentToken != null + && !currentToken.equals("(") + && !currentToken.equals(")") + && !currentToken.equals(";") + && !currentToken.equals("&&") + && !currentToken.equals("||")) { + words.add(currentToken); + nextToken(); + } + + if (words.isEmpty()) { + throw new RuntimeException("Empty command"); + } + + return new Command(words); + } + + private void consume(String expected) { + if (currentToken.equals(expected)) { + nextToken(); + } else { + throw new RuntimeException( + "Expected '" + expected + "' but found '" + currentToken + "'"); + } + } + + private void nextToken() { + // Skip whitespace + while (position < input.length() && Character.isWhitespace(input.charAt(position))) { + position++; + } + + if (position >= input.length()) { + currentToken = null; + return; + } + + char c = input.charAt(position); + + // Handle special characters + if (c == '(' || c == ')' || c == ';') { + currentToken = String.valueOf(c); + position++; + } else if (c == '&' && position + 1 < input.length() && input.charAt(position + 1) == '&') { + currentToken = "&&"; + position += 2; + } else if (c == '|' && position + 1 < input.length() && input.charAt(position + 1) == '|') { + currentToken = "||"; + position += 2; + } else { + // Parse a word token + StringBuilder sb = new StringBuilder(); + boolean inQuotes = false; + char quoteChar = 0; + + while (position < input.length()) { + c = input.charAt(position); + if (!inQuotes) { + if (Character.isWhitespace(c) + || c == '(' + || c == ')' + || c == ';' + || (c == '&' + && position + 1 < input.length() + && input.charAt(position + 1) == '&') + || (c == '|' + && position + 1 < input.length() + && input.charAt(position + 1) == '|')) { + break; + } else if (c == '"' || c == '\'') { + inQuotes = true; + quoteChar = c; + } + } else { + if (c == quoteChar) { + // Add closing quote character to preserve it + inQuotes = false; + } + } + sb.append(c); + position++; + } + + currentToken = sb.toString(); + } + } + + // AST node types + public interface Node {} + + public static class Commands implements Node { + public final List elements; + + public Commands(List elements) { + this.elements = elements; + } + } + + public static class Command implements Node { + public final List words; + + public Command(List words) { + this.words = words; + } + } + + public static class Group implements Node { + public final Commands commands; + + public Group(Commands commands) { + this.commands = commands; + } + } + + public static class Separator implements Node { + public final String type; + + public Separator(String type) { + this.type = type; + } + } +} diff --git a/src/main/java/org/codejive/jpm/util/FileUtils.java b/src/main/java/org/codejive/jpm/util/FileUtils.java index e8ab06c..3daee69 100644 --- a/src/main/java/org/codejive/jpm/util/FileUtils.java +++ b/src/main/java/org/codejive/jpm/util/FileUtils.java @@ -3,7 +3,9 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.HashSet; import java.util.List; @@ -82,4 +84,12 @@ private static void copyDependency(Path artifact, Path directory, boolean noLink } Files.copy(artifact, target, StandardCopyOption.REPLACE_EXISTING); } + + public static Path safePath(String w) { + try { + return Paths.get(w); + } catch (InvalidPathException e) { + return null; + } + } } diff --git a/src/main/java/org/codejive/jpm/util/ScriptUtils.java b/src/main/java/org/codejive/jpm/util/ScriptUtils.java index d0e4e7e..49b9f28 100644 --- a/src/main/java/org/codejive/jpm/util/ScriptUtils.java +++ b/src/main/java/org/codejive/jpm/util/ScriptUtils.java @@ -4,11 +4,16 @@ import java.io.File; import java.io.IOException; import java.io.InputStreamReader; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** Utility class for executing scripts with path conversion and variable substitution. */ @@ -18,55 +23,225 @@ public class ScriptUtils { * Executes a script command with variable substitution and path conversion. * * @param command The command to execute - * @param args The arguments to pass to the command * @param classpath The classpath to use for {{deps}} substitution * @param verbose If true, prints the command before execution * @return The exit code of the executed command * @throws IOException if an error occurred during execution * @throws InterruptedException if the execution was interrupted */ - public static int executeScript( - String command, List args, List classpath, boolean verbose) + public static int executeScript(String command, List classpath, boolean verbose) throws IOException, InterruptedException { - if (args != null && !args.isEmpty()) { - command += - args.stream() - .map(ScriptUtils::quoteArgument) - .collect(Collectors.joining(" ", " ", "")); - } - - String processedCommand = processCommand(command, classpath); - if (verbose) { - System.out.println("> " + processedCommand); - } - String[] commandTokens = - isWindows() - ? new String[] {"cmd.exe", "/c", processedCommand} - : new String[] {"/bin/sh", "-c", processedCommand}; - ProcessBuilder pb = new ProcessBuilder(commandTokens); - pb.redirectErrorStream(true); - Process p = pb.start(); - BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream())); - br.lines().forEach(System.out::println); - return p.waitFor(); + // We do a first pass of processing just to know the size of the command + String tmpCommand = processCommand(command, classpath, null); + boolean useArgsFiles = + (isWindows() && tmpCommand.length() > 8000) + || (!isWindows() && tmpCommand.length() > 32000); + + if (!usingSubstitutions(command)) { + // First try to parse the command + CommandsParser parser = new CommandsParser(command); + CommandsParser.Commands commands = parser.parse(); + if (commands != null) { + command = suggestSubstitutions(commands); + } + } + + try (ArgsFiles argsFiles = new ArgsFiles()) { + // Process the command for variable substitution and path conversion + String processedCommand = + processCommand(command, classpath, useArgsFiles ? argsFiles::create : null); + if (verbose) { + System.out.println("> " + processedCommand); + } + + // Prepare the command for execution using a shell or cmd + String[] commandTokens = + isWindows() + ? new String[] {"cmd.exe", "/c", processedCommand} + : new String[] {"/bin/sh", "-c", processedCommand}; + + ProcessBuilder pb = new ProcessBuilder(commandTokens); + pb.redirectErrorStream(true); + Process p = pb.start(); + BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream())); + br.lines().forEach(System.out::println); + return p.waitFor(); + } } - static String quoteArgument(String arg) { + public static String quoteArgument(String arg) { return arg; } + // Is the command using any substitutions? (we ignore {{deps}} here) + private static boolean usingSubstitutions(String command) { + command = command.replace("{]}", "\u0007"); + Pattern pattern = Pattern.compile("\\{([/:;~]|\\./.*|~/.*)}|@\\[.*]"); + return pattern.matcher(command).find(); + } + + static String suggestSubstitutions(CommandsParser.Commands commands) { + StringBuilder cmd = new StringBuilder(); + for (CommandsParser.Node node : commands.elements) { + if (node instanceof CommandsParser.Command) { + cmd.append(suggestSubstitutions((CommandsParser.Command) node)); + } else if (node instanceof CommandsParser.Group) { + CommandsParser.Group group = (CommandsParser.Group) node; + String groupStr = suggestSubstitutions(group.commands); + cmd.append("(").append(groupStr).append(")"); + } else if (node instanceof CommandsParser.Separator) { + CommandsParser.Separator sep = (CommandsParser.Separator) node; + if (sep.type.equals(";")) { + cmd.append(" {;} "); + } else { + cmd.append(" ").append(sep.type).append(" "); + } + } + } + return cmd.toString(); + } + + static String suggestSubstitutions(CommandsParser.Command command) { + StringBuilder cmd = new StringBuilder(); + if (command.words.isEmpty()) { + return ""; + } + + for (int i = 0; i < command.words.size(); i++) { + String w = command.words.get(i); + String neww = suggestClassPathSubstitution(w); + if (neww == null) { + neww = suggestPathSubstitution(w); + } + if (neww != null) { + command.words.set(i, neww); + } + } + + // For commands that support class paths, we look for {{deps}} and + // suggest putting @[ ... ] around the entire argument + for (int i = 0; i < command.words.size(); i++) { + String w = command.words.get(i); + String neww = null; + if (i == 0) { + if (w.startsWith("@")) { + neww = w.substring(1); + } else if (supportsArgsFiles(w)) { + neww = w; + } + } else if (w.contains("{{deps}}")) { + // Let's add @[ ... ] around the entire argument + neww = "@[" + w.replace("]", "{]}") + "]"; + } + if (neww != null) { + command.words.set(i, neww); + } + } + + String commandStr = String.join(" ", command.words); + cmd.append(commandStr); + return cmd.toString(); + } + + private static boolean supportsArgsFiles(String cmd) { + Path p = FileUtils.safePath(cmd); + if (p == null) { + return false; + } + String name = p.getFileName().toString().toLowerCase(Locale.ENGLISH); + if (name.endsWith(".exe") || name.endsWith(".bat") || name.endsWith(".cmd")) { + name = name.substring(0, name.lastIndexOf('.')); + } + return name.equals("java") + || name.equals("javac") + || name.equals("javadoc") + || name.equals("javap") + || name.equals("jdeps") + || name.equals("jmod"); + } + + private static String suggestPathSubstitution(String path) { + Path p = FileUtils.safePath(path); + if (p == null || p.isAbsolute() || p.getNameCount() < 2) { + return null; + } + // Looks like a relative path, let's suggest {./...} or {~/...} + if (p.startsWith("~")) { + // Do nothing + } else if (!p.startsWith(".")) { + path = "./" + path; + } + return "{" + path + "}"; + } + + private static String suggestClassPathSubstitution(String classpath) { + String[] parts = classpath.split(":"); + if (parts.length <= 1) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (String path : parts) { + Path p = FileUtils.safePath(path); + if (p == null || p.isAbsolute()) { + return null; + } + if (sb.length() == 0) { + // Looks like a relative path, let's suggest {./...} or {~/...} + if (p.startsWith("~")) { + // Do nothing + } else if (!p.startsWith(".")) { + sb.append("./"); + } + } else { + sb.append(":"); + } + sb.append(path); + } + return "{" + sb.toString() + "}"; + } + /** * Processes a command by performing variable substitution and path conversion. * * @param command The raw command * @param classpath The classpath to use for {{deps}} substitution + * @param argsFileCreator A function that creates an args file given its content, or null to not + * use args files * @return The processed command */ - static String processCommand(String command, List classpath) { + static String processCommand( + String command, List classpath, Function argsFileCreator) { String result = command; // Substitute {{deps}} with the classpath - if (result.contains("{{deps}}")) { + result = substituteDeps(result, classpath); + + // Find all occurrences of {./...} and {~/...} and replace them with os paths + // This also handles classpath constructs with multiple paths separated by : + result = substitutePaths(result); + + // Special replacements for dealing with paths and classpath separators + // in a cross-platform way + result = result.replace("{/}", File.separator); + result = result.replace("{:}", File.pathSeparator); + result = + result.replace( + "{~}", + isWindows() ? Paths.get(System.getProperty("user.home")).toString() : "~"); + + // Special replacement {;} for dealing with multi-command actions in a + // cross-platform way + result = result.replace("{;}", isWindows() ? "&" : ";"); + + // Now we go look for any @[ ... ] and write the contents to an args file + // replacing the construct with an @ followed by the args file path + result = substituteArgsFiles(result, argsFileCreator); + + return result; + } + + private static String substituteDeps(String command, List classpath) { + if (command.contains("{{deps}}")) { String classpathStr = ""; if (classpath != null && !classpath.isEmpty()) { classpathStr = @@ -74,13 +249,14 @@ static String processCommand(String command, List classpath) { .map(Path::toString) .collect(Collectors.joining(File.pathSeparator)); } - result = result.replace("{{deps}}", classpathStr); + command = command.replace("{{deps}}", classpathStr); } + return command; + } - // Find all occurrences of {./...} and {~/...} and replace them with os paths - // This also handles classpath constructs with multiple paths separated by : - java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\{([.~]/[^}]*)}"); - java.util.regex.Matcher matcher = pattern.matcher(result); + private static String substitutePaths(String command) { + Pattern pattern = Pattern.compile("\\{([.~]/[^}]*)}"); + Matcher matcher = pattern.matcher(command); StringBuilder sb = new StringBuilder(); while (matcher.find()) { String path = matcher.group(1); @@ -101,29 +277,41 @@ static String processCommand(String command, List classpath) { } }) .collect(Collectors.joining(File.pathSeparator)); - replacedPath = replacedPath.replace("\\", "\\\\"); } else { // If we're not on Windows, we assume the path is already correct replacedPath = path; } - matcher.appendReplacement(sb, replacedPath); + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacedPath)); } matcher.appendTail(sb); - result = sb.toString(); - - // Special replacements for dealing with paths and classpath separators - // in cross-platform way - result = result.replace("{/}", File.separator); - result = result.replace("{:}", File.pathSeparator); - result = - result.replace( - "{~}", - isWindows() ? Paths.get(System.getProperty("user.home")).toString() : "~"); - - // Special replacement {;} for dealing with multi-command actions in a - // cross-platform way - result = result.replace("{;}", isWindows() ? "&" : ";"); + return sb.toString(); + } + private static String substituteArgsFiles( + String command, Function argsFileCreator) { + // First we replace any {]} with a placeholder to avoid messing up the regex + String tmpCommand = command.replace("{]}", "\u0007"); + // Now we go look for any @[ ... ] and write the contents to an args file + // replacing the construct with an @ followed by the args file path + Pattern pattern = Pattern.compile("@\\[([^]]*)]"); + Matcher matcher = pattern.matcher(tmpCommand); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + // Extract the contents inside @[ ... ] + String argsContent = matcher.group(1).trim(); + if (argsFileCreator != null) { + // Write contents to a file and replace @[ ... ] with @ + String path = argsFileCreator.apply(argsContent).toString(); + matcher.appendReplacement(sb, Matcher.quoteReplacement("@" + path)); + } else { + // Just remove the @[ ... ] and keep the contents as-is + matcher.appendReplacement(sb, Matcher.quoteReplacement(argsContent)); + } + } + matcher.appendTail(sb); + String result = sb.toString(); + // Now we put back any ] we might have had + result = result.replace("\u0007", "{]}"); return result; } @@ -135,4 +323,33 @@ public static boolean isWindows() { .replaceAll("[^a-z0-9]+", ""); return os.startsWith("win"); } + + static class ArgsFiles implements AutoCloseable { + final List files = new ArrayList<>(); + + public Path create(String content) { + try { + File argsFile = File.createTempFile("jpm-args-", ".txt"); + argsFile.deleteOnExit(); + Path path = argsFile.toPath(); + Files.write(path, content.getBytes()); + files.add(path); + return path; + } catch (IOException e) { + throw new RuntimeException("Failed to create args file", e); + } + } + + @Override + public void close() { + for (Path file : files) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + // Ignore + } + } + files.clear(); + } + } } diff --git a/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java b/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java index 39fbe8c..1111ffc 100644 --- a/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java +++ b/src/test/java/org/codejive/jpm/DoCommandPerformanceTest.java @@ -8,7 +8,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; -import java.util.List; import org.codejive.jpm.util.ScriptUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -47,10 +46,7 @@ void testPerformanceOptimizationNoDepsVariable() throws IOException { // Mock ScriptUtils to verify classpath is empty when no {{deps}} variable try (MockedStatic mockedScriptUtils = Mockito.mockStatic(ScriptUtils.class)) { mockedScriptUtils - .when( - () -> - ScriptUtils.executeScript( - anyString(), any(List.class), any(), anyBoolean())) + .when(() -> ScriptUtils.executeScript(anyString(), any(), anyBoolean())) .thenReturn(0); CommandLine cmd = Main.getCommandLine(); @@ -62,10 +58,7 @@ void testPerformanceOptimizationNoDepsVariable() throws IOException { mockedScriptUtils.verify( () -> ScriptUtils.executeScript( - eq("true"), - eq(Collections.emptyList()), - eq(Collections.emptyList()), - eq(true)), + eq("true"), eq(Collections.emptyList()), eq(true)), times(1)); } } @@ -82,10 +75,7 @@ void testCaseInsensitiveDepsVariable() throws IOException { try (MockedStatic mockedScriptUtils = Mockito.mockStatic(ScriptUtils.class)) { mockedScriptUtils - .when( - () -> - ScriptUtils.executeScript( - anyString(), any(List.class), any(), anyBoolean())) + .when(() -> ScriptUtils.executeScript(anyString(), any(), anyBoolean())) .thenReturn(0); CommandLine cmd = Main.getCommandLine(); @@ -93,12 +83,7 @@ void testCaseInsensitiveDepsVariable() throws IOException { // Test exact match - should resolve classpath cmd.execute("do", "exact"); mockedScriptUtils.verify( - () -> - ScriptUtils.executeScript( - contains("{{deps}}"), - eq(Collections.emptyList()), - any(), - eq(true)), + () -> ScriptUtils.executeScript(contains("{{deps}}"), any(), eq(true)), times(1)); mockedScriptUtils.clearInvocations(); @@ -110,7 +95,6 @@ void testCaseInsensitiveDepsVariable() throws IOException { ScriptUtils.executeScript( eq("java -cp ${DEPS} MainClass"), eq(Collections.emptyList()), - eq(Collections.emptyList()), eq(true)), times(1)); @@ -123,7 +107,6 @@ void testCaseInsensitiveDepsVariable() throws IOException { ScriptUtils.executeScript( eq("java -cp mydeps MainClass"), eq(Collections.emptyList()), - eq(Collections.emptyList()), eq(true)), times(1)); } diff --git a/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java b/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java index 45aa14b..55365ee 100644 --- a/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java +++ b/src/test/java/org/codejive/jpm/util/ScriptUtilsTest.java @@ -22,7 +22,7 @@ void testProcessCommandWithDepsSubstitution() throws Exception { Paths.get("deps/lib3.jar")); String command = "java -cp {{deps}} MainClass"; - String result = ScriptUtils.processCommand(command, classpath); + String result = ScriptUtils.processCommand(command, classpath, null); // Use the actual paths from the classpath as they would be processed String expectedClasspath = @@ -38,7 +38,7 @@ void testProcessCommandWithDepsSubstitution() throws Exception { void testProcessCommandWithoutDepsSubstitution() throws Exception { List classpath = Arrays.asList(Paths.get("deps/lib1.jar")); String command = "echo Hello World"; - String result = ScriptUtils.processCommand(command, classpath); + String result = ScriptUtils.processCommand(command, classpath, null); // Command should remain unchanged since no {{deps}} variable assertThat(result).isEqualTo("echo Hello World"); } @@ -47,7 +47,7 @@ void testProcessCommandWithoutDepsSubstitution() throws Exception { void testProcessCommandWithEmptyClasspath() throws Exception { List classpath = Collections.emptyList(); String command = "java -cp \"{{deps}}\" MainClass"; - String result = ScriptUtils.processCommand(command, classpath); + String result = ScriptUtils.processCommand(command, classpath, null); // {{deps}} should be replaced with empty string assertThat(result).isEqualTo("java -cp \"\" MainClass"); } @@ -55,7 +55,7 @@ void testProcessCommandWithEmptyClasspath() throws Exception { @Test void testProcessCommandWithNullClasspath() throws Exception { String command = "java -cp \"{{deps}}\" MainClass"; - String result = ScriptUtils.processCommand(command, null); + String result = ScriptUtils.processCommand(command, null, null); // {{deps}} should be replaced with empty string assertThat(result).isEqualTo("java -cp \"\" MainClass"); } @@ -63,7 +63,7 @@ void testProcessCommandWithNullClasspath() throws Exception { @Test void testProcessCommandWithPathTokens() throws Exception { String command = "java -cp .{/}libs{:}.{/}ext{:}{~}{/}usrlibs MainClass"; - String result = ScriptUtils.processCommand(command, null); + String result = ScriptUtils.processCommand(command, null, null); String expectedPath = ScriptUtils.isWindows() ? ".\\libs;.\\ext;" + System.getProperty("user.home") + "\\usrlibs" @@ -74,7 +74,7 @@ void testProcessCommandWithPathTokens() throws Exception { @Test void testProcessCommandWithPathReplacement() throws Exception { String command = "java -cp {./libs:./ext:~/usrlibs} MainClass"; - String result = ScriptUtils.processCommand(command, null); + String result = ScriptUtils.processCommand(command, null, null); String expectedPath = ScriptUtils.isWindows() ? ".\\libs;.\\ext;" + System.getProperty("user.home") + "\\usrlibs" @@ -85,7 +85,7 @@ void testProcessCommandWithPathReplacement() throws Exception { @Test void testProcessCommandWithPathReplacement2() throws Exception { String command = "java -cp {~/usrlibs:./libs:./ext} MainClass"; - String result = ScriptUtils.processCommand(command, null); + String result = ScriptUtils.processCommand(command, null, null); String expectedPath = ScriptUtils.isWindows() ? System.getProperty("user.home") + "\\usrlibs;.\\libs;.\\ext" @@ -97,7 +97,7 @@ void testProcessCommandWithPathReplacement2() throws Exception { void testProcessCommandWithMultipleDepsReferences() throws Exception { List classpath = Arrays.asList(Paths.get("deps/lib1.jar")); String command = "java -cp {{deps}} MainClass && java -cp {{deps}} TestClass"; - String result = ScriptUtils.processCommand(command, classpath); + String result = ScriptUtils.processCommand(command, classpath, null); // Use the actual path as it would be processed String expectedPath = classpath.get(0).toString(); @@ -119,28 +119,36 @@ void testIsWindows() { } @Test - void testExecuteScriptSimpleCommand() { - // Test that executeScript can be called without throwing exceptions - // We can't easily test the actual execution without mocking ProcessBuilder - assertThatCode( - () -> { - // Use a simple command that should work on most systems - List classpath = Collections.emptyList(); - // Note: This test is limited because we can't easily mock - // ProcessBuilder - // In a real scenario, you might want to use a mocking framework - }) - .doesNotThrowAnyException(); + void testProcessCommandMultipleSubs() throws Exception { + // Integration test combining variable substitution and path handling + List classpath = + Arrays.asList(Paths.get("deps/lib1.jar"), Paths.get("deps/lib2.jar")); + + String command = "java -cp .{:}.{/}libs{/}*{:}{{deps}} -Dmyprop=value MainClass arg1"; + String result = ScriptUtils.processCommand(command, classpath, null); + + // Use the actual paths as they would be processed + String expectedClasspath = + String.join( + File.pathSeparator, + classpath.get(0).toString(), + classpath.get(1).toString()); + if (ScriptUtils.isWindows()) { + expectedClasspath = ".;.\\libs\\*;" + expectedClasspath; + } else { + expectedClasspath = ".:./libs/*:" + expectedClasspath; + } + assertThat(result) + .isEqualTo("java -cp " + expectedClasspath + " -Dmyprop=value MainClass arg1"); } @Test - void testProcessCommandIntegration() throws Exception { - // Integration test combining variable substitution and path handling + void testProcessCommandArgsFilesSkip() throws Exception { List classpath = Arrays.asList(Paths.get("deps/lib1.jar"), Paths.get("deps/lib2.jar")); - String command = "java -cp .{:}.{/}libs{/}*{:}{{deps}} -Dmyprop=value MainClass arg1"; - String result = ScriptUtils.processCommand(command, classpath); + String command = "java -cp @[.{:}.{/}libs{/}*{:}{{deps}}] -Dmyprop=value MainClass arg1"; + String result = ScriptUtils.processCommand(command, classpath, null); // Use the actual paths as they would be processed String expectedClasspath = @@ -156,4 +164,97 @@ void testProcessCommandIntegration() throws Exception { assertThat(result) .isEqualTo("java -cp " + expectedClasspath + " -Dmyprop=value MainClass arg1"); } + + @Test + void testProcessCommandArgsFilesUse() throws Exception { + List classpath = + Arrays.asList(Paths.get("deps/lib1.jar"), Paths.get("deps/lib2.jar")); + + try (ScriptUtils.ArgsFiles argsFiles = new ScriptUtils.ArgsFiles()) { + String command = + "java -cp @[.{:}.{/}libs{/}*{:}{{deps}}] -Dmyprop=value MainClass arg1"; + String result = ScriptUtils.processCommand(command, classpath, argsFiles::create); + + assertThat(argsFiles.files).hasSize(1); + assertThat(argsFiles.files.get(0)).exists(); + + String expectedArgsFile = argsFiles.files.get(0).toString(); + assertThat(result) + .isEqualTo("java -cp @" + expectedArgsFile + " -Dmyprop=value MainClass arg1"); + + String expectedContents = + String.join( + File.pathSeparator, + ".", + Paths.get("./libs/STAR").toString().replace("STAR", "*"), + classpath.get(0).toString(), + classpath.get(1).toString()); + assertThat(argsFiles.files.get(0)).hasContent(expectedContents); + } + } + + @Test + void testProcessCommandArgsFilesUse2() throws Exception { + List classpath = + Arrays.asList(Paths.get("deps/lib1.jar"), Paths.get("deps/lib2.jar")); + + try (ScriptUtils.ArgsFiles argsFiles = new ScriptUtils.ArgsFiles()) { + String command = + "java @[-cp .{:}.{/}libs{/}*{:}{{deps}} -Dmyprop=value MainClass arg1]"; + String result = ScriptUtils.processCommand(command, classpath, argsFiles::create); + + assertThat(argsFiles.files).hasSize(1); + assertThat(argsFiles.files.get(0)).exists(); + + String expectedArgsFile = argsFiles.files.get(0).toString(); + assertThat(result).isEqualTo("java @" + expectedArgsFile); + + String expectedContents = + "-cp " + + String.join( + File.pathSeparator, + ".", + Paths.get("./libs/STAR").toString().replace("STAR", "*"), + classpath.get(0).toString(), + classpath.get(1).toString()) + + " -Dmyprop=value MainClass arg1"; + assertThat(argsFiles.files.get(0)).hasContent(expectedContents); + } + } + + @Test + void testProcessMultiCommandArgsFilesUse() throws Exception { + List classpath = + Arrays.asList(Paths.get("deps/lib1.jar"), Paths.get("deps/lib2.jar")); + + try (ScriptUtils.ArgsFiles argsFiles = new ScriptUtils.ArgsFiles()) { + String command = + "javac -cp @[.{/}libs{/}*{:}{{deps}}] -d classes --source-path src {src/MainClass.java} && java -cp @[classes{:}.{/}libs{/}*{:}{{deps}}] -Dmyprop=value MainClass arg1"; + String result = ScriptUtils.processCommand(command, classpath, argsFiles::create); + + assertThat(argsFiles.files).hasSize(2); + assertThat(argsFiles.files.get(0)).exists(); + assertThat(argsFiles.files.get(1)).exists(); + + String expectedArgsFile1 = argsFiles.files.get(0).toString(); + String expectedArgsFile2 = argsFiles.files.get(1).toString(); + assertThat(result) + .isEqualTo( + "javac -cp @" + + expectedArgsFile1 + + " -d classes --source-path src {src/MainClass.java} && java -cp @" + + expectedArgsFile2 + + " -Dmyprop=value MainClass arg1"); + + String expectedContents1 = + String.join( + File.pathSeparator, + Paths.get("./libs/STAR").toString().replace("STAR", "*"), + classpath.get(0).toString(), + classpath.get(1).toString()); + assertThat(argsFiles.files.get(0)).hasContent(expectedContents1); + String expectedContents2 = "classes" + File.pathSeparator + expectedContents1; + assertThat(argsFiles.files.get(1)).hasContent(expectedContents2); + } + } } From db947918f9267268b663e326e542045645e2fbf2 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Tue, 2 Sep 2025 20:02:48 +0200 Subject: [PATCH 2/3] fix: not supplying repos could cause Resolver issues --- src/main/java/org/codejive/jpm/util/Resolver.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/codejive/jpm/util/Resolver.java b/src/main/java/org/codejive/jpm/util/Resolver.java index fa1ca0b..f826371 100644 --- a/src/main/java/org/codejive/jpm/util/Resolver.java +++ b/src/main/java/org/codejive/jpm/util/Resolver.java @@ -64,8 +64,11 @@ public static List resolveArtifacts( artifacts.stream() .map(a -> new Dependency(a, JavaScopes.RUNTIME)) .collect(Collectors.toList()); - ContextOverrides overrides = - ContextOverrides.create().withUserSettings(true).repositories(repositories).build(); + ContextOverrides.Builder ctxb = ContextOverrides.create().withUserSettings(true); + if (repositories != null && !repositories.isEmpty()) { + ctxb.repositories(repositories); + } + ContextOverrides overrides = ctxb.build(); Runtime runtime = Runtimes.INSTANCE.getRuntime(); try (Context context = runtime.create(overrides)) { CollectRequest collectRequest = From 19bb6fb248b8f56d5cffcbd10c173645232cf812 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Tue, 2 Sep 2025 20:03:45 +0200 Subject: [PATCH 3/3] fix: fixed sync issue and path command --- src/main/java/org/codejive/jpm/Jpm.java | 28 +++++++++++-------- src/main/java/org/codejive/jpm/Main.java | 14 +++++----- .../java/org/codejive/jpm/util/FileUtils.java | 20 +++++++++---- .../util/{SyncStats.java => SyncResult.java} | 8 +++++- 4 files changed, 46 insertions(+), 24 deletions(-) rename src/main/java/org/codejive/jpm/util/{SyncStats.java => SyncResult.java} (69%) diff --git a/src/main/java/org/codejive/jpm/Jpm.java b/src/main/java/org/codejive/jpm/Jpm.java index 327aa1f..8ea5f0e 100644 --- a/src/main/java/org/codejive/jpm/Jpm.java +++ b/src/main/java/org/codejive/jpm/Jpm.java @@ -86,11 +86,11 @@ public Jpm build() { * * @param artifactNames The artifacts to copy. * @param sync Whether to sync the target directory or not. - * @return An instance of {@link SyncStats} containing the statistics of the copy operation. + * @return An instance of {@link SyncResult} containing the statistics of the copy operation. * @throws IOException If an error occurred during the copy operation. * @throws DependencyResolutionException If an error occurred during the dependency resolution. */ - public SyncStats copy(String[] artifactNames, boolean sync) + public SyncResult copy(String[] artifactNames, boolean sync) throws IOException, DependencyResolutionException { return copy(artifactNames, Collections.emptyMap(), sync); } @@ -101,11 +101,11 @@ public SyncStats copy(String[] artifactNames, boolean sync) * @param artifactNames The artifacts to copy. * @param repos A map of additional repository names to URLs where artifacts can be found. * @param sync Whether to sync the target directory or not. - * @return An instance of {@link SyncStats} containing the statistics of the copy operation. + * @return An instance of {@link SyncResult} containing the statistics of the copy operation. * @throws IOException If an error occurred during the copy operation. * @throws DependencyResolutionException If an error occurred during the dependency resolution. */ - public SyncStats copy(String[] artifactNames, Map repos, boolean sync) + public SyncResult copy(String[] artifactNames, Map repos, boolean sync) throws IOException, DependencyResolutionException { List files = Resolver.create(artifactNames, repos).resolvePaths(); return FileUtils.syncArtifacts(files, directory, noLinks, !sync); @@ -141,11 +141,11 @@ private static String artifactGav(Artifact artifact) { * basically means sync-copying the artifacts to the target directory. * * @param artifactNames The artifacts to install. - * @return An instance of {@link SyncStats} containing the statistics of the install operation. + * @return An instance of {@link SyncResult} containing the statistics of the install operation. * @throws IOException If an error occurred during the install operation. * @throws DependencyResolutionException If an error occurred during the dependency resolution. */ - public SyncStats install(String[] artifactNames) + public SyncResult install(String[] artifactNames) throws IOException, DependencyResolutionException { return install(artifactNames, Collections.emptyMap()); } @@ -158,18 +158,18 @@ public SyncStats install(String[] artifactNames) * * @param artifactNames The artifacts to install. * @param extraRepos A map of additional repository names to URLs where artifacts can be found. - * @return An instance of {@link SyncStats} containing the statistics of the install operation. + * @return An instance of {@link SyncResult} containing the statistics of the install operation. * @throws IOException If an error occurred during the install operation. * @throws DependencyResolutionException If an error occurred during the dependency resolution. */ - public SyncStats install(String[] artifactNames, Map extraRepos) + public SyncResult install(String[] artifactNames, Map extraRepos) throws IOException, DependencyResolutionException { AppInfo appInfo = AppInfo.read(); String[] artifacts = getArtifacts(artifactNames, appInfo); Map repos = getRepositories(extraRepos, appInfo); if (artifacts.length > 0) { List files = Resolver.create(artifacts, repos).resolvePaths(); - SyncStats stats = FileUtils.syncArtifacts(files, directory, noLinks, true); + SyncResult stats = FileUtils.syncArtifacts(files, directory, noLinks, true); if (artifactNames.length > 0) { for (String dep : artifactNames) { int p = dep.lastIndexOf(':'); @@ -182,7 +182,7 @@ public SyncStats install(String[] artifactNames, Map extraRepos) } return stats; } else { - return new SyncStats(); + return new SyncResult(); } } @@ -216,7 +216,13 @@ public List path(String[] artifactNames, Map extraRepos) String[] deps = getArtifacts(artifactNames, appInfo); Map repos = getRepositories(extraRepos, appInfo); if (deps.length > 0) { - return Resolver.create(deps, repos).resolvePaths(); + List files = Resolver.create(deps, repos).resolvePaths(); + if (artifactNames.length > 0) { + return files; + } else { + SyncResult result = FileUtils.syncArtifacts(files, directory, noLinks, true); + return result.files; + } } else { return Collections.emptyList(); } diff --git a/src/main/java/org/codejive/jpm/Main.java b/src/main/java/org/codejive/jpm/Main.java index 694f273..e17ec4d 100644 --- a/src/main/java/org/codejive/jpm/Main.java +++ b/src/main/java/org/codejive/jpm/Main.java @@ -5,7 +5,7 @@ //DEPS org.jline:jline-console-ui:3.30.5 org.jline:jline-terminal-jni:3.30.5 //DEPS org.slf4j:slf4j-api:2.0.17 org.slf4j:slf4j-simple:2.0.17 //SOURCES Jpm.java config/AppInfo.java util/CommandsParser.java util/FileUtils.java util/Resolver.java -//SOURCES util/ScriptUtils.java util/SearchResult.java util/SearchUtils.java util/SyncStats.java util/Version.java +//SOURCES util/ScriptUtils.java util/SearchResult.java util/SearchUtils.java util/SyncResult.java util/Version.java // spotless:on package org.codejive.jpm; @@ -17,7 +17,7 @@ import java.util.*; import java.util.concurrent.Callable; import java.util.stream.Collectors; -import org.codejive.jpm.util.SyncStats; +import org.codejive.jpm.util.SyncResult; import org.codejive.jpm.util.Version; import org.jline.consoleui.elements.InputValue; import org.jline.consoleui.elements.ListChoice; @@ -78,7 +78,7 @@ static class Copy implements Callable { @Override public Integer call() throws Exception { - SyncStats stats = + SyncResult stats = Jpm.builder() .directory(artifactsMixin.depsMixin.directory) .noLinks(artifactsMixin.depsMixin.noLinks) @@ -142,7 +142,7 @@ public Integer call() throws Exception { String selectedArtifact = getSelectedId(result, "item"); String artifactAction = getSelectedId(result, "action"); if ("install".equals(artifactAction)) { - SyncStats stats = + SyncResult stats = Jpm.builder() .directory(depsMixin.directory) .noLinks(depsMixin.noLinks) @@ -152,7 +152,7 @@ public Integer call() throws Exception { printStats(stats); } } else if ("copy".equals(artifactAction)) { - SyncStats stats = + SyncResult stats = Jpm.builder() .directory(depsMixin.directory) .noLinks(depsMixin.noLinks) @@ -286,7 +286,7 @@ static class Install implements Callable { @Override public Integer call() throws Exception { - SyncStats stats = + SyncResult stats = Jpm.builder() .directory(optionalArtifactsMixin.depsMixin.directory) .noLinks(optionalArtifactsMixin.depsMixin.noLinks) @@ -627,7 +627,7 @@ static class QuietMixin { private boolean quiet; } - private static void printStats(SyncStats stats) { + private static void printStats(SyncResult stats) { System.err.printf( "Artifacts new: %d, updated: %d, deleted: %d%n", (Integer) stats.copied, (Integer) stats.updated, (Integer) stats.deleted); diff --git a/src/main/java/org/codejive/jpm/util/FileUtils.java b/src/main/java/org/codejive/jpm/util/FileUtils.java index 3daee69..12f42a8 100644 --- a/src/main/java/org/codejive/jpm/util/FileUtils.java +++ b/src/main/java/org/codejive/jpm/util/FileUtils.java @@ -21,13 +21,13 @@ public class FileUtils { * @param directory target directory * @param noLinks if true, copy artifacts instead of creating symbolic links * @param noDelete if true, do not delete artifacts that are no longer needed - * @return An instance of {@link SyncStats} with statistics about the synchronization + * @return An instance of {@link SyncResult} with statistics about the synchronization * @throws IOException if an error occurred during the synchronization */ - public static SyncStats syncArtifacts( + public static SyncResult syncArtifacts( List artifacts, Path directory, boolean noLinks, boolean noDelete) throws IOException { - SyncStats stats = new SyncStats(); + SyncResult stats = new SyncResult(); // Make sure the target directory exists Files.createDirectories(directory); @@ -46,14 +46,20 @@ public static SyncStats syncArtifacts( // Copy artifacts for (Path artifact : artifacts) { String artifactName = artifact.getFileName().toString(); + artifactsToDelete.remove(artifactName); Path target = directory.resolve(artifactName); + stats.files.add(target); if (!Files.exists(target)) { copyDependency(artifact, directory, noLinks); - artifactsToDelete.remove(artifactName); stats.copied++; } else if (Files.isSymbolicLink(target) == noLinks) { copyDependency(artifact, directory, noLinks); stats.updated++; + } else if (Files.size(target) != Files.size(artifact) + || !Files.getLastModifiedTime(target) + .equals(Files.getLastModifiedTime(artifact))) { + copyDependency(artifact, directory, noLinks); + stats.updated++; } } @@ -82,7 +88,11 @@ private static void copyDependency(Path artifact, Path directory, boolean noLink // fall through and try again by simply copying the file } } - Files.copy(artifact, target, StandardCopyOption.REPLACE_EXISTING); + Files.copy( + artifact, + target, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES); } public static Path safePath(String w) { diff --git a/src/main/java/org/codejive/jpm/util/SyncStats.java b/src/main/java/org/codejive/jpm/util/SyncResult.java similarity index 69% rename from src/main/java/org/codejive/jpm/util/SyncStats.java rename to src/main/java/org/codejive/jpm/util/SyncResult.java index ae770ca..2dc8eca 100644 --- a/src/main/java/org/codejive/jpm/util/SyncStats.java +++ b/src/main/java/org/codejive/jpm/util/SyncResult.java @@ -1,7 +1,13 @@ package org.codejive.jpm.util; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + /** Utility class for keeping track of synchronization statistics. */ -public class SyncStats { +public class SyncResult { + public List files = new ArrayList<>(); + /** The number of new artifacts that were copied. */ public int copied;