// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Projects;
/// <summary>
/// The build tool that owns a Java AppHost project.
/// </summary>
internal enum JavaAppHostToolchain
{
/// <summary>
/// No build tool. The AppHost is a single file compiled directly with <c>javac</c>.
/// </summary>
Javac,
/// <summary>
/// A Maven project, identified by <c>pom.xml</c>.
/// </summary>
Maven,
/// <summary>
/// A Gradle project, identified by <c>build.gradle</c> or <c>build.gradle.kts</c>.
/// </summary>
Gradle
}
/// <summary>
/// Selects the build tool for a Java AppHost from the files on disk and rewrites the runtime spec to
/// match, mirroring what <see cref="TypeScriptAppHostToolchainResolver"/> does for package managers.
/// </summary>
/// <remarks>
/// <para>
/// Java AppHosts started life as a single <c>AppHost.java</c> compiled with <c>javac</c>, which needs
/// nothing but a JDK. That remains the default and the fallback: adopting a build tool is opt-in, and
/// is done by adding a <c>pom.xml</c> or <c>build.gradle</c>. A project model is what unlocks
/// third-party dependencies and full IDE support, because the Java language server derives its
/// classpath from build files.
/// </para>
/// <para>
/// Under a build tool the AppHost is still launched with a plain <c>java</c> command rather than
/// through <c>mvn exec:java</c> or <c>gradle run</c>. Those run the application inside, or as a child
/// of, the build tool's own JVM, so console signals reach the build tool instead of the AppHost and an
/// orderly shutdown never happens. Launching directly also means the arguments the CLI appends arrive
/// as real argv entries instead of being spliced into a shell string.
/// </para>
/// </remarks>
internal static class JavaAppHostToolchainResolver
{
private const string MavenPomFileName = "pom.xml";
private const string GradleBuildFileName = "build.gradle";
private const string GradleKotlinBuildFileName = "build.gradle.kts";
private const string GradleSettingsFileName = "settings.gradle";
private const string GradleKotlinSettingsFileName = "settings.gradle.kts";
/// <summary>
/// Directory the resolved runtime dependencies are copied into, relative to the build output.
/// A directory of JARs is used rather than a classpath string because the JVM expands a
/// <c>dir/*</c> classpath entry itself, so no shell and no generated argument file is needed.
/// </summary>
private const string DependencyDirectoryName = "aspire-deps";
/// <summary>
/// Gradle init script that adds a dependency-copying task to whatever project is being built.
/// </summary>
/// <remarks>
/// An init script is used so a Gradle AppHost needs no cooperation from the user's build file.
/// Maven ships <c>dependency:copy-dependencies</c> out of the box, but Gradle has no built-in
/// equivalent, and requiring an edit to <c>build.gradle</c> would make adopting Gradle a worse
/// experience than Maven for no reason.
/// See https://docs.gradle.org/current/userguide/init_scripts.html.
/// </remarks>
private const string GradleInitScript = $$"""
// Generated by Aspire. Adds the task that stages the AppHost's runtime classpath so the
// AppHost can be launched with a plain `java` command instead of through Gradle.
//
// Sync rather than Copy: the classpath is the whole directory, so a JAR left behind by a
// dependency that was upgraded or removed would still be loaded. Sync deletes whatever is
// in the destination but not in the configuration, and stays incremental while doing it.
// https://docs.gradle.org/current/dsl/org.gradle.api.tasks.Sync.html
allprojects {
tasks.register("aspireCopyDependencies", Sync) {
from configurations.named("runtimeClasspath")
into layout.buildDirectory.dir("{{DependencyDirectoryName}}")
}
}
""";
internal const string GradleInitScriptRelativePath = ".aspire/aspire-gradle-init.gradle";
/// <summary>
/// <see cref="GradleInitScriptRelativePath"/> with the separators this platform uses.
/// </summary>
private static string GradleInitScriptPath => GradleInitScriptRelativePath.Replace('/', Path.DirectorySeparatorChar);
public static bool IsJavaLanguage(LanguageInfo? language)
{
return language is not null &&
language.LanguageId.Value.Equals(KnownLanguageId.Java, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Conventional source root a build tool compiles from. When the AppHost lives here the build
/// file is at the project root, three directories up.
/// https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html
/// </summary>
private static readonly string[] s_conventionalSourceRootSegments = ["src", "main", "java"];
public static JavaAppHostToolchainResolution Resolve(DirectoryInfo appHostDirectory, ILogger? logger = null)
{
var resolution = ResolveWithReason(appHostDirectory);
logger?.LogDebug(
"Selected Java AppHost build tool '{BuildTool}' rooted at '{ProjectDirectory}' because {Reason}.",
resolution.Toolchain,
resolution.ProjectDirectory.FullName,
resolution.Reason);
return resolution;
}
internal static JavaAppHostToolchainResolution ResolveWithReason(DirectoryInfo appHostDirectory)
{
// The AppHost directory is checked first, then the project root implied by the conventional
// source layout. An unbounded walk up the tree is deliberately avoided: a pom.xml in some
// arbitrary ancestor usually belongs to an unrelated project that happens to contain the
// AppHost folder, whereas src/main/java is only ever a build tool's source root.
foreach (var candidate in GetCandidateProjectDirectories(appHostDirectory))
{
if (File.Exists(Path.Combine(candidate.FullName, MavenPomFileName)))
{
return new(JavaAppHostToolchain.Maven, candidate, $"{MavenPomFileName} found in {candidate.FullName}");
}
foreach (var gradleBuildFileName in new[] { GradleBuildFileName, GradleKotlinBuildFileName })
{
if (File.Exists(Path.Combine(candidate.FullName, gradleBuildFileName)))
{
return new(JavaAppHostToolchain.Gradle, candidate, $"{gradleBuildFileName} found in {candidate.FullName}");
}
}
}
return new(JavaAppHostToolchain.Javac, appHostDirectory, $"no build file found in {appHostDirectory.FullName}");
}
private static IEnumerable<DirectoryInfo> GetCandidateProjectDirectories(DirectoryInfo appHostDirectory)
{
yield return appHostDirectory;
// Walk back out of src/main/java only when the directory names actually match, so an AppHost
// that merely sits three levels deep is not mistaken for a conventional project layout.
var candidate = appHostDirectory;
foreach (var segment in s_conventionalSourceRootSegments.Reverse())
{
if (!string.Equals(candidate.Name, segment, PathComparison) || candidate.Parent is null)
{
yield break;
}
candidate = candidate.Parent;
}
yield return candidate;
}
/// <summary>
/// Locates the wrapper for <paramref name="toolchain"/>, preferring the project's own directory and
/// otherwise walking up to the build root.
/// </summary>
/// <remarks>
/// A Gradle multi-project build has exactly one <c>gradlew</c>, beside the <c>settings.gradle</c>
/// that declares the subprojects, and a Maven multi-module repository keeps <c>mvnw</c> beside the
/// aggregator POM. An AppHost that is one of those modules carries only its own build file, so
/// requiring a wrapper next to it would reject the standard layout outright.
/// <para>
/// An ancestor only qualifies when it also holds that tool's build-root marker and is not
/// world-writable, and the walk stops at the directory holding <c>.git</c> so a submodule or nested
/// clone uses its own wrapper rather than the outer repository's. These are the same rules
/// <c>Aspire.Hosting.Java</c>'s JavaBuildToolResolver applies to hosted resources; the logic is
/// duplicated rather than shared because the CLI does not reference the hosting package.
/// </para>
/// </remarks>
private static string? FindWrapper(DirectoryInfo projectDirectory, string wrapperName, JavaAppHostToolchain toolchain)
{
for (var directory = projectDirectory; directory is not null; directory = directory.Parent)
{
var candidate = Path.Combine(directory.FullName, wrapperName);
var isProjectDirectory = directory.FullName == projectDirectory.FullName;
// The project directory is named by the AppHost, so a wrapper beside it is the developer's
// own instruction and needs no further qualification. Ancestors are inferred instead.
if (File.Exists(candidate)
&& (isProjectDirectory
|| (IsBuildRoot(directory.FullName, toolchain)
&& !IsWorldWritable(directory.FullName)
&& !IsWorldWritable(candidate))))
{
return candidate;
}
// A worktree or submodule records .git as a file rather than a directory, so both count.
var gitPath = Path.Combine(directory.FullName, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
{
return null;
}
}
return null;
}
/// <summary>
/// Returns whether a directory is the root of a build for <paramref name="toolchain"/>.
/// </summary>
private static bool IsBuildRoot(string directory, JavaAppHostToolchain toolchain) => toolchain switch
{
// Gradle requires a settings file at the root of a multi-project build; that is the directory
// the wrapper is generated into. https://docs.gradle.org/current/userguide/multi_project_builds.html
JavaAppHostToolchain.Gradle => File.Exists(Path.Combine(directory, GradleSettingsFileName))
|| File.Exists(Path.Combine(directory, GradleKotlinSettingsFileName)),
// A Maven aggregator is itself a project, so its POM is the marker.
// https://maven.apache.org/guides/introduction/introduction-to-the-pom.html
JavaAppHostToolchain.Maven => File.Exists(Path.Combine(directory, MavenPomFileName)),
_ => false
};
/// <summary>
/// Returns whether any user on the machine can write to <paramref name="path"/>.
/// </summary>
/// <remarks>
/// Only inferred ancestors are checked. On a shared machine an AppHost under a world-writable
/// directory such as <c>/tmp</c> could otherwise pick up a wrapper another user planted beside a
/// <c>pom.xml</c>, and the CLI would execute it with the developer's privileges before anything is
/// built. Applied to the wrapper file as well as its directory, because rewriting a file in place
/// needs write permission on the file rather than on the directory holding it.
/// <para>
/// Group-writable is deliberately not rejected: distributions that enable user private groups pair
/// a umask of 002 with a group per user, so an ordinary checkout is mode 775 and rejecting it
/// would break wrapper resolution for a large share of Linux users.
/// </para>
/// <para>
/// Windows uses ACLs that <see cref="UnixFileMode"/> does not describe, and .NET reports
/// <see cref="UnixFileMode.None"/> there, so the check is skipped.
/// </para>
/// </remarks>
private static bool IsWorldWritable(string path)
{
if (OperatingSystem.IsWindows())
{
return false;
}
try
{
return (File.GetUnixFileMode(path) & UnixFileMode.OtherWrite) != 0;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// A path whose mode cannot be read cannot be shown safe, so treat it as unusable.
return true;
}
}
private static StringComparison PathComparison =>
OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
public static string GetDisplayName(JavaAppHostToolchain toolchain)
{
return toolchain switch
{
JavaAppHostToolchain.Javac => "Java",
JavaAppHostToolchain.Maven => "Java (Maven)",
JavaAppHostToolchain.Gradle => "Java (Gradle)",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
/// <summary>
/// Returns how the build tool should be invoked. The wrapper checked into the project is required,
/// so the AppHost always builds with the tool version the project pins.
/// </summary>
/// <param name="projectDirectory">Directory holding the build file and the wrapper.</param>
/// <param name="appHostDirectory">Directory the command runs in, which the wrapper path is relative to.</param>
/// <param name="toolchain">The resolved build tool.</param>
internal static JavaToolInvocation GetToolInvocation(
DirectoryInfo projectDirectory,
DirectoryInfo appHostDirectory,
JavaAppHostToolchain toolchain)
{
var (wrapperName, generateCommand) = toolchain switch
{
// -N keeps the goal from recursing into the modules of a multi-module build.
JavaAppHostToolchain.Maven => (OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw", "mvn -N wrapper:wrapper"),
JavaAppHostToolchain.Gradle => (OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew", "gradle wrapper"),
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
var wrapperPath = FindWrapper(projectDirectory, wrapperName, toolchain);
// A globally installed Maven or Gradle is deliberately not used as a fallback: the wrapper pins the
// tool version in the repository, so every machine builds the AppHost with the same one. Falling
// back silently would make the AppHost build depend on whatever the developer happens to have.
if (wrapperPath is null)
{
throw new InvalidOperationException(
$"The Java AppHost project in '{projectDirectory.FullName}' declares a {GetDisplayName(toolchain)} " +
$"build but ships no {wrapperName}, and none was found at an enclosing build root. Generate one " +
$"with '{generateCommand}', or remove the build file to build the AppHost with javac instead.");
}
if (!OperatingSystem.IsWindows())
{
// Invoked through "sh" rather than executed directly because a wrapper checked out on
// Windows, or committed without its mode bit, arrives without the executable bit and
// exec fails with "Permission denied". The wrappers are POSIX shell scripts and are
// documented to be run that way, so "sh <path>" is always valid. This matches how the
// hosted Java resources invoke wrappers (JavaHostingExtensions.WrapperInvocationFor).
//
// The absolute path is kept because the process is started without a shell, so a bare
// "mvnw" would be looked up on PATH and never found in the project directory.
return new JavaToolInvocation("sh", [wrapperPath]);
}
// On Windows the wrappers are batch files. Launching one directly with redirected stdout can
// silently produce no output (see NpmRunner, which hits the same problem with npm.cmd), so the
// command interpreter runs it instead.
//
// The path is made relative to the working directory to keep it short, and "call" runs it. That
// matters because cmd.exe strips quotes in a way that does not match how ProcessStartInfo
// escapes arguments: when the *first* token on the line is quoted, cmd removes that quote and
// the last one on the line, mangling everything in between. A wrapper reached through a
// directory whose name contains a space is quoted, so with the wrapper first the line would be
// mangled; with "call" first the first character is never a quote and the rule cannot apply.
// "call" is also how one batch file invokes another: it returns control and propagates the
// exit code. See the quote-processing rules printed by `cmd /?`.
var relativeWrapperPath = Path.GetRelativePath(appHostDirectory.FullName, wrapperPath);
return new JavaToolInvocation(
Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe",
["/c", "call", relativeWrapperPath]);
}
/// <summary>
/// Rewrites the runtime spec for the resolved build tool. The <see cref="JavaAppHostToolchain.Javac"/>
/// spec is returned unchanged so an AppHost without build files behaves exactly as before.
/// </summary>
public static RuntimeSpec ApplyToRuntimeSpec(RuntimeSpec baseRuntimeSpec, JavaAppHostToolchainResolution resolution, DirectoryInfo appHostDirectory)
{
var toolchain = resolution.Toolchain;
if (toolchain == JavaAppHostToolchain.Javac)
{
return baseRuntimeSpec;
}
var invocation = GetToolInvocation(resolution.ProjectDirectory, appHostDirectory, toolchain);
// Commands run with the AppHost directory as their working directory, which is not the project
// directory in the src/main/java layout, so every path below is rewritten relative to it.
var projectPath = GetRelativeProjectPath(resolution.ProjectDirectory, appHostDirectory);
var classesDirectory = CombineProjectPath(projectPath, GetClassesDirectory(toolchain));
var dependencyDirectory = CombineProjectPath(projectPath, GetDependencyDirectory(toolchain));
return new RuntimeSpec
{
Language = baseRuntimeSpec.Language,
DisplayName = GetDisplayName(toolchain),
CodeGenLanguage = baseRuntimeSpec.CodeGenLanguage,
DetectionPatterns = baseRuntimeSpec.DetectionPatterns,
Initialize = baseRuntimeSpec.Initialize,
InstallDependencies = CreateInstallCommand(toolchain, invocation, projectPath),
PreExecute = [CreateCompileCommand(toolchain, baseRuntimeSpec, projectPath, classesDirectory, dependencyDirectory)],
Execute = CreateExecuteCommand(classesDirectory, dependencyDirectory),
WatchExecute = baseRuntimeSpec.WatchExecute,
PublishExecute = baseRuntimeSpec.PublishExecute,
ExtensionLaunchCapability = baseRuntimeSpec.ExtensionLaunchCapability,
CertificateBundleEnvironmentVariable = baseRuntimeSpec.CertificateBundleEnvironmentVariable,
MigrationFiles = baseRuntimeSpec.MigrationFiles
};
}
/// <summary>
/// Writes the Gradle init script when the resolved toolchain needs it.
/// </summary>
/// <remarks>
/// This is not done through <see cref="RuntimeSpec.MigrationFiles"/> because those are written
/// with a plain file write that fails when the parent directory does not exist yet, and the
/// script lives under <c>.aspire</c>.
/// </remarks>
public static async Task EnsureToolchainFilesExistAsync(
JavaAppHostToolchainResolution resolution,
CancellationToken cancellationToken)
{
if (resolution.Toolchain != JavaAppHostToolchain.Gradle)
{
return;
}
// Written into the project directory rather than the AppHost directory so it sits alongside
// the build file it augments, which is where the --init-script argument points.
var scriptPath = Path.Combine(resolution.ProjectDirectory.FullName, GradleInitScriptPath);
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
// Rewritten every run rather than only when missing, so a script left behind by an older
// Aspire version cannot silently keep staging dependencies the wrong way.
//
// Written through a temporary sibling and moved into place because writing directly truncates
// first: a second `aspire run` starting while this one is mid-write leaves Gradle reading a
// half-written script, which fails with a syntax error that points at the generated file rather
// than at the race. File.Move within a directory is atomic on every platform we support.
var temporaryPath = $"{scriptPath}.{Environment.ProcessId}.tmp";
try
{
await File.WriteAllTextAsync(temporaryPath, GradleInitScript, cancellationToken).ConfigureAwait(false);
File.Move(temporaryPath, scriptPath, overwrite: true);
}
catch
{
try
{
File.Delete(temporaryPath);
}
catch
{
// Losing a temp file matters far less than the original failure, which is rethrown.
}
throw;
}
}
/// <summary>
/// Removes staged dependencies that the build tool will not remove itself, immediately before it
/// restages them.
/// </summary>
/// <remarks>
/// The AppHost is launched with the whole staging directory on its classpath as <c>dir/*</c>, and
/// <c>dependency:copy-dependencies</c> only ever adds to that directory. Upgrading a dependency
/// changes the file name, so the old JAR stays behind and both versions end up on the classpath,
/// where which one wins is left to directory order.
/// <para>
/// Gradle needs nothing here because its staging task is a <c>Sync</c>, which prunes the destination
/// itself and stays incremental while doing it. Maven has no equivalent.
/// </para>
/// <para>
/// This is called from the install path rather than when the toolchain is resolved so that clearing
/// and restaging cannot be separated. Clearing without restaging would leave the AppHost with an
/// empty classpath.
/// </para>
/// </remarks>
public static void ClearStagedDependencies(JavaAppHostToolchainResolution resolution)
{
if (resolution.Toolchain != JavaAppHostToolchain.Maven)
{
return;
}
var dependencyDirectory = Path.Combine(
resolution.ProjectDirectory.FullName,
GetDependencyDirectory(resolution.Toolchain));
try
{
if (Directory.Exists(dependencyDirectory))
{
Directory.Delete(dependencyDirectory, recursive: true);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Continuing here would defeat the only reason this method exists. Maven adds the new
// versioned JAR beside the stale one, both end up on the "dir/*" classpath, and which one
// the JVM loads is left to directory order — a failure that surfaces later as an unrelated
// NoSuchMethodError. Better to stop now with a message that names the directory.
throw new InvalidOperationException(
$"The staged dependency directory '{dependencyDirectory}' could not be cleared, so the " +
"AppHost would run with both the old and new versions of any upgraded dependency on its " +
"classpath. Close anything holding files in that directory, or delete it manually, then " +
"run the command again.",
ex);
}
}
/// <summary>
/// Path from the AppHost directory to the project directory, or <see langword="null"/> when they are
/// the same. Null rather than "." so the common flat layout keeps clean, unprefixed relative paths.
/// </summary>
private static string? GetRelativeProjectPath(DirectoryInfo projectDirectory, DirectoryInfo appHostDirectory)
{
var relativePath = Path.GetRelativePath(appHostDirectory.FullName, projectDirectory.FullName);
return relativePath == "." ? null : relativePath;
}
private static string CombineProjectPath(string? projectPath, string path)
{
return projectPath is null ? path : Path.Combine(projectPath, path);
}
/// <summary>
/// Arguments that point the build tool at a project other than its working directory. Both tools
/// need this because the AppHost is launched from its own directory, not the project root.
/// </summary>
private static string[] GetProjectSelectionArgs(JavaAppHostToolchain toolchain, string? projectPath)
{
if (projectPath is null)
{
return [];
}
return toolchain switch
{
// https://maven.apache.org/ref/current/maven-embedder/cli.html
JavaAppHostToolchain.Maven => ["-f", Path.Combine(projectPath, MavenPomFileName)],
// https://docs.gradle.org/current/userguide/command_line_interface.html
JavaAppHostToolchain.Gradle => ["-p", projectPath],
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
private static string GetClassesDirectory(JavaAppHostToolchain toolchain)
{
return toolchain switch
{
// Both are the build tool's conventional output for main classes.
// https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html
// https://docs.gradle.org/current/userguide/java_plugin.html#sec:java_project_layout
JavaAppHostToolchain.Maven => Path.Combine("target", "classes"),
JavaAppHostToolchain.Gradle => Path.Combine("build", "classes", "java", "main"),
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
private static string GetDependencyDirectory(JavaAppHostToolchain toolchain)
{
return toolchain switch
{
JavaAppHostToolchain.Maven => Path.Combine("target", DependencyDirectoryName),
JavaAppHostToolchain.Gradle => Path.Combine("build", DependencyDirectoryName),
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
private static CommandSpec CreateInstallCommand(
JavaAppHostToolchain toolchain,
JavaToolInvocation invocation,
string? projectPath)
{
var toolArgs = toolchain switch
{
// Batch mode keeps the transfer progress spinner out of the CLI's captured output.
// Only runtime-scoped dependencies are staged: test and provided dependencies are not
// on the application's runtime classpath and staging them can shadow real versions.
JavaAppHostToolchain.Maven => (string[])
[
"-B", "-q",
.. GetProjectSelectionArgs(toolchain, projectPath),
"dependency:copy-dependencies",
// Maven resolves a relative outputDirectory against the project's base directory, so
// the path is expressed relative to the project rather than the working directory.
$"-DoutputDirectory={GetDependencyDirectory(toolchain)}",
"-DincludeScope=runtime"
],
JavaAppHostToolchain.Gradle =>
[
"-q",
.. GetProjectSelectionArgs(toolchain, projectPath),
"--init-script", CombineProjectPath(projectPath, GradleInitScriptPath),
"aspireCopyDependencies"
],
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
return invocation.CreateCommand(toolArgs);
}
/// <summary>
/// Compiles the AppHost with javac, against the dependency classpath the build tool staged.
/// </summary>
/// <remarks>
/// The build tool resolves dependencies but does not compile, because neither Maven nor Gradle can
/// be told from the command line about the generated SDK under <c>.aspire/modules</c>. Maven has no
/// user property for an extra source root or compiler argument — <c>compilerArgs</c>,
/// <c>compilerArgument</c>, and the source roots are all pom-only — so <c>mvnw compile</c> fails with
/// "package aspire does not exist" no matter what is passed after the goal. Editing the user's
/// <c>pom.xml</c> to add a source root is not an option either: the build file belongs to them.
/// <para>
/// Compiling with javac instead keeps the AppHost building the same way under all three toolchains,
/// and the build tool keeps the two jobs it is actually needed for: resolving third-party
/// dependencies, and giving the IDE a real project model.
/// </para>
/// <para>
/// The consequence, which is worth knowing: compiler configuration in <c>pom.xml</c> or
/// <c>build.gradle</c> — annotation processors such as Lombok, custom lint settings, an alternative
/// compiler — does not apply to <c>AppHost.java</c>.
/// </para>
/// </remarks>
/// <summary>
/// Inputs that decide what the AppHost is compiled <em>against</em>, as opposed to what it is
/// compiled <em>from</em>.
/// </summary>
/// <remarks>
/// A dependency bump changes no Java source at all: the build descriptor changes, and the build
/// tool then stages a differently-named JAR. Without these the cached bytecode is reused and the
/// AppHost runs against an API that is no longer on its classpath, usually surfacing as a
/// NoSuchMethodError at the point of use rather than anything that names the real cause.
/// <para>
/// The staged dependency directory is listed non-recursively and its JARs are outside the check's
/// source extensions on purpose. Staging runs on every launch and can rewrite the JARs in place,
/// so only the directory's own timestamp is meaningful — and that moves exactly when the resolved
/// set changes, which is the question being asked.
/// </para>
/// </remarks>
private static string[] GetDependencyInputs(JavaAppHostToolchain toolchain, string? projectPath, string dependencyDirectory)
{
string[] buildDescriptors = toolchain switch
{
JavaAppHostToolchain.Maven => [MavenPomFileName],
// The version catalog is Gradle's other place for dependency coordinates, and it is
// conventionally a sibling of the settings file rather than of the build file.
// https://docs.gradle.org/current/userguide/version_catalogs.html
JavaAppHostToolchain.Gradle =>
[
GradleBuildFileName,
GradleKotlinBuildFileName,
GradleSettingsFileName,
GradleKotlinSettingsFileName,
Path.Combine("gradle", "libs.versions.toml")
],
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
return
[
.. buildDescriptors.Select(descriptor => CombineProjectPath(projectPath, descriptor)),
dependencyDirectory
];
}
private static CommandSpec CreateCompileCommand(JavaAppHostToolchain toolchain, RuntimeSpec baseRuntimeSpec, string? projectPath, string classesDirectory, string dependencyDirectory)
{
var baseCompile = baseRuntimeSpec.PreExecute?.FirstOrDefault()
?? throw new InvalidOperationException("The Java runtime spec has no compile step to adapt for a build tool.");
var baseArgs = baseCompile.Args ?? [];
// "dir/*" is expanded by the JVM's own launcher rather than the shell, so it needs no shell and
// stays correct when the directory is empty.
// https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html#standard-options
string[] addedArgs =
[
"-classpath", Path.Combine(dependencyDirectory, "*"),
// The AppHost directory is the working directory and is also a source root under both
// supported layouts, so this is what lets the AppHost reference the project's own classes.
// It has to be set explicitly because javac otherwise defaults the source path to the class
// path, which -classpath has just replaced.
//
// src/main/java is included as well for the flat layout, where AppHost.java sits beside
// pom.xml at the project root and the project's own sources are one source root down. The
// build tool only stages dependencies here - it never compiles - so without this entry a
// reference to the project's own class fails with "cannot find symbol". Under the
// conventional layout the working directory already is src/main/java, which makes the extra
// entry a path that does not exist; javac ignores those, so it costs nothing.
"-sourcepath", string.Join(Path.PathSeparator, ".", Path.Combine("src", "main", "java")),
// Output goes to the build tool's own directory rather than the javac toolchain's
// .java-build, so that `mvn clean` / `gradle clean` removes it and it is already ignored by
// any Maven or Gradle .gitignore.
"-d", classesDirectory
];
// The javac options and the source arguments are reused from the base spec rather than repeated
// here, so the two toolchains cannot drift apart. The output directory is the one thing replaced.
var outputIndex = Array.IndexOf(baseArgs, "-d");
var args = outputIndex >= 0 && outputIndex + 1 < baseArgs.Length
? (string[])[.. baseArgs[..outputIndex], .. addedArgs, .. baseArgs[(outputIndex + 2)..]]
: [.. addedArgs, .. baseArgs];
return new CommandSpec
{
Command = baseCompile.Command,
Args = args,
// The stamp has to move with the output directory. Left in .java-build it would survive a
// `mvn clean`, and the next launch would skip a compile whose classes had just been deleted.
UpToDateCheck = baseCompile.UpToDateCheck is null
? null
: new CommandUpToDateCheck
{
// The base spec guesses at "src/main/java" relative to the AppHost directory, which
// is only right for the flat layout. Here the project's real source root is known,
// so it replaces the guess: under the conventional layout that root *is* the AppHost
// directory, and it has to be scanned recursively for the packages beneath it.
Inputs =
[
.. baseCompile.UpToDateCheck.Inputs.Where(static input => !input.StartsWith("src/main/java", StringComparison.Ordinal)),
$"{CombineProjectPath(projectPath, Path.Combine("src", "main", "java"))}/**",
.. GetDependencyInputs(toolchain, projectPath, dependencyDirectory)
],
FileExtensions = baseCompile.UpToDateCheck.FileExtensions,
StampFile = Path.Combine(classesDirectory, Path.GetFileName(baseCompile.UpToDateCheck.StampFile))
}
};
}
private static CommandSpec CreateExecuteCommand(string classesDirectory, string dependencyDirectory)
{
// "dir/*" is expanded by the JVM into every JAR in that directory. It is not a shell glob, so
// it works with no shell involved and stays correct when the directory is empty.
// https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html#standard-options-for-java
var classPath = string.Join(Path.PathSeparator, [classesDirectory, Path.Combine(dependencyDirectory, "*")]);
return new CommandSpec
{
Command = "java",
// {args} is deliberately absent. When no argument contains that placeholder the CLI
// appends its arguments as separate argv entries, whereas substituting the placeholder
// joins them into one space-separated string that the AppHost then has to re-split.
Args = ["-cp", classPath, "AppHost"]
};
}
}
/// <summary>
/// How a build tool wrapper is launched: the executable, plus any arguments that must precede the
/// tool's own. Windows needs a command interpreter in front of the batch wrapper, other platforms
/// invoke it directly.
/// </summary>
internal readonly record struct JavaToolInvocation(string Command, string[] PrefixArgs)
{
public CommandSpec CreateCommand(string[] args)
{
return new CommandSpec
{
Command = Command,
Args = [.. PrefixArgs, .. args]
};
}
}
/// <summary>
/// The build tool selected for a Java AppHost, the directory its build file lives in, and why it was chosen.
/// </summary>
/// <remarks>
/// <see cref="ProjectDirectory"/> is not always the AppHost's own directory: with the conventional
/// <c>src/main/java/AppHost.java</c> layout the build file is at the project root. Commands are still
/// launched from the AppHost directory, so paths are made relative to it rather than to the project.
/// </remarks>
internal readonly record struct JavaAppHostToolchainResolution(
JavaAppHostToolchain Toolchain,
DirectoryInfo ProjectDirectory,
string Reason);